| # SPDX-License-Identifier: Apache-2.0 |
| # |
| # Modifications by Apache Solr contributors; see git log for details. |
| # Licensed under the Apache License, Version 2.0. |
| # |
| # The OpenSearch Contributors require contributions made to |
| # this file be licensed under the Apache-2.0 license or a |
| # compatible open source license. |
| # Modifications Copyright OpenSearch Contributors. See |
| # GitHub history for details. |
| # 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 glob |
| import json |
| import logging |
| import os |
| import random |
| import re |
| import sys |
| import shutil |
| import tempfile |
| import urllib.error |
| |
| import jinja2 |
| import jinja2.exceptions |
| import jsonschema |
| import tabulate |
| from jinja2 import meta, select_autoescape |
| |
| from solrorbit import exceptions, time, PROGRAM_NAME, config, version |
| from solrorbit.workload import params, workload |
| from solrorbit.utils import io, collections, convert, net, console, modules, opts, repo |
| |
| |
| class WorkloadSyntaxError(exceptions.InvalidSyntax): |
| """ |
| Raised whenever a syntax problem is encountered when loading the workload specification. |
| """ |
| |
| |
| class WorkloadProcessor: |
| def on_after_load_workload(self, input_workload, **kwargs): |
| """ |
| This method is called by Solr Orbit after a workload has been loaded. Implementations are expected to modify the |
| provided workload object in place. |
| |
| :param workload: The current workload. |
| """ |
| |
| def on_prepare_workload(self, workload, data_root_dir): |
| """ |
| This method is called by Solr Orbit after the "after_load_workload" phase. Here, any data that is necessary for |
| benchmark run should be prepared, e.g. by downloading data or generating it. Implementations should |
| be aware that this method might be called on a different machine than "on_after_load_workload" and they cannot |
| share any state in between phases. |
| |
| :param workload: The current workload. This parameter should be treated as effectively immutable. Any modifications |
| will not be reflected in subsequent phases of the benchmark. |
| :param data_root_dir: The data root directory on the current machine as configured by the user. |
| :return: an Iterable[Callable, dict] of function/parameter pairs to be executed by the prepare workload's executor |
| actors. |
| """ |
| return [] |
| |
| |
| class WorkloadProcessorRegistry: |
| def __init__(self, cfg): |
| self.required_processors = [TaskFilterWorkloadProcessor(cfg), TestModeWorkloadProcessor(cfg), QueryRandomizerWorkloadProcessor(cfg)] |
| self.workload_processors = [] |
| self.offline = cfg.opts("system", "offline.mode") |
| self.test_mode = cfg.opts("workload", "test.mode.enabled", mandatory=False, default_value=False) |
| self.base_config = cfg |
| self.custom_configuration = False |
| |
| def register_workload_processor(self, processor): |
| if not self.custom_configuration: |
| # given processor should become the only element |
| self.workload_processors = [] |
| if not isinstance(processor, DefaultWorkloadPreparator): |
| # stop resetting self.workload_processors |
| self.custom_configuration = True |
| if hasattr(processor, "downloader"): |
| processor.downloader = Downloader(self.offline, self.test_mode) |
| if hasattr(processor, "decompressor"): |
| processor.decompressor = Decompressor() |
| self.workload_processors.append(processor) |
| |
| @property |
| def processors(self): |
| if not self.custom_configuration: |
| self.register_workload_processor(DefaultWorkloadPreparator(self.base_config)) |
| return [*self.required_processors, *self.workload_processors] |
| |
| |
| def workloads(cfg): |
| """ |
| |
| Lists all known workloads. Note that users can specify a distribution version so if different workloads are available for |
| different versions, this will be reflected in the output. |
| |
| :param cfg: The config object. |
| :return: A list of workloads that are available for the provided distribution version or else for the master version. |
| """ |
| repo = workload_repo(cfg) |
| return [_load_single_workload(cfg, repo, workload_name) for workload_name in repo.workload_names] |
| |
| |
| def list_workloads(cfg): |
| available_workloads = workloads(cfg) |
| only_auto_generated_test_procedures = all(t.default_test_procedure.auto_generated for t in available_workloads) |
| |
| data = [] |
| for t in available_workloads: |
| line = [t.name, t.description, convert.number_to_human_string(t.number_of_documents), |
| convert.bytes_to_human_string(t.compressed_size_in_bytes), |
| convert.bytes_to_human_string(t.uncompressed_size_in_bytes)] |
| if not only_auto_generated_test_procedures: |
| line.append(t.default_test_procedure) |
| line.append(",".join(map(str, t.test_procedures))) |
| data.append(line) |
| |
| headers = ["Name", "Description", "Documents", "Compressed Size", "Uncompressed Size"] |
| if not only_auto_generated_test_procedures: |
| headers.append("Default TestProcedure") |
| headers.append("All TestProcedures") |
| |
| console.println("Available workloads:\n") |
| console.println(tabulate.tabulate(tabular_data=data, headers=headers)) |
| |
| |
| def workload_info(cfg): |
| def format_task(t, indent="", num="", suffix=""): |
| msg = "{}{}{}".format(indent, num, str(t)) |
| if t.clients > 1: |
| msg += " ({} clients)".format(t.clients) |
| msg += suffix |
| return msg |
| |
| def test_procedure_info(c): |
| if not c.auto_generated: |
| msg = "TestProcedure [{}]".format(c.name) |
| if c.default: |
| msg += " (run by default)" |
| console.println(msg, underline="=", overline="=") |
| if c.description: |
| console.println("\n{}".format(c.description)) |
| |
| console.println("\nSchedule:", underline="-") |
| console.println("") |
| for num, task in enumerate(c.schedule, start=1): |
| if task.nested: |
| console.println(format_task(task, suffix=":", num="{}. ".format(num))) |
| for leaf_num, leaf_task in enumerate(task, start=1): |
| console.println(format_task(leaf_task, indent="\t", num="{}.{} ".format(num, leaf_num))) |
| else: |
| console.println(format_task(task, num="{}. ".format(num))) |
| |
| t = load_workload(cfg) |
| console.println("Showing details for workload [{}]:\n".format(t.name)) |
| console.println("* Description: {}".format(t.description)) |
| if t.number_of_documents: |
| console.println("* Documents: {}".format(convert.number_to_human_string(t.number_of_documents))) |
| console.println("* Compressed Size: {}".format(convert.bytes_to_human_string(t.compressed_size_in_bytes))) |
| console.println("* Uncompressed Size: {}".format(convert.bytes_to_human_string(t.uncompressed_size_in_bytes))) |
| console.println("") |
| |
| if t.selected_test_procedure: |
| test_procedure_info(t.selected_test_procedure) |
| else: |
| for test_procedure in t.test_procedures: |
| test_procedure_info(test_procedure) |
| console.println("") |
| |
| |
| def load_workload(cfg): |
| """ |
| |
| Loads a workload |
| |
| :param cfg: The config object. It contains the name of the workload to load. |
| :return: The loaded workload. |
| """ |
| repo = workload_repo(cfg) |
| return _load_single_workload(cfg, repo, repo.workload_name) |
| |
| |
| def _load_single_workload(cfg, workload_repository, workload_name): |
| try: |
| workload_dir = workload_repository.workload_dir(workload_name) |
| reader = WorkloadFileReader(cfg) |
| current_workload = reader.read(workload_name, workload_repository.workload_file(workload_name), workload_dir) |
| tpr = WorkloadProcessorRegistry(cfg) |
| has_plugins = load_workload_plugins(cfg, workload_name, register_workload_processor=tpr.register_workload_processor) |
| current_workload.has_plugins = has_plugins |
| for processor in tpr.processors: |
| processor.on_after_load_workload(current_workload) |
| return current_workload |
| except FileNotFoundError as e: |
| logging.getLogger(__name__).exception("Cannot load workload [%s]", workload_name) |
| raise exceptions.SystemSetupError(f"Cannot load workload [{workload_name}]. " |
| f"List the available workloads with [{PROGRAM_NAME} list workloads].") from e |
| except BaseException: |
| logging.getLogger(__name__).exception("Cannot load workload [%s]", workload_name) |
| raise |
| |
| |
| def load_workload_plugins(cfg, |
| workload_name, |
| register_runner=None, |
| register_scheduler=None, |
| register_workload_processor=None, |
| force_update=False): |
| """ |
| Loads plugins that are defined for the current workload (as specified by the configuration). |
| |
| :param cfg: The config object. |
| :param workload_name: Name of the workload for which plugins should be loaded. |
| :param register_runner: An optional function where runners can be registered. |
| :param register_scheduler: An optional function where custom schedulers can be registered. |
| :param register_workload_processor: An optional function where workload processors can be registered. |
| :param force_update: If set to ``True`` this ensures that the workload is first updated from the remote repository. |
| Defaults to ``False``. |
| :return: True iff this workload defines plugins and they have been loaded. |
| """ |
| repo = workload_repo(cfg, fetch=force_update, update=force_update) |
| workload_plugin_path = repo.workload_dir(workload_name) |
| logging.getLogger(__name__).debug("Invoking plugin_reader with name [%s] resolved to path [%s]", workload_name, workload_plugin_path) |
| plugin_reader = WorkloadPluginReader(workload_plugin_path, register_runner, register_scheduler, register_workload_processor) |
| |
| if plugin_reader.can_load(): |
| plugin_reader.load() |
| return True |
| else: |
| return False |
| |
| |
| def set_absolute_data_path(cfg, t): |
| """ |
| Sets an absolute data path on all document files in this workload. |
| Internally we store only relative paths in the workload as long as possible |
| as the data root directory may be different on each host. In the end we need to have an absolute path though when we want to read the |
| file on the target host. |
| |
| :param cfg: The config object. |
| :param t: The workload to modify. |
| """ |
| |
| def first_existing(root_dirs, f): |
| for root_dir in root_dirs: |
| p = os.path.join(root_dir, f) |
| if os.path.exists(p): |
| return p |
| return None |
| |
| for corpus in t.corpora: |
| data_root = data_dir(cfg, t.name, corpus.name) |
| for document_set in corpus.documents: |
| # At this point we can assume that the file is available locally. Check which path exists and set it. |
| if document_set.document_archive: |
| document_set.document_archive = first_existing(data_root, document_set.document_archive) |
| if document_set.document_file: |
| if corpus.streaming_ingestion: |
| document_set.data_dir = data_root[0] |
| else: |
| document_set.document_file = first_existing(data_root, document_set.document_file) |
| |
| |
| def is_simple_workload_mode(cfg): |
| return cfg.exists("workload", "workload.path") |
| |
| |
| def workload_path(cfg): |
| repo = workload_repo(cfg) |
| workload_name = repo.workload_name |
| workload_dir = repo.workload_dir(workload_name) |
| return workload_dir |
| |
| |
| def workload_repo(cfg, fetch=True, update=True): |
| if is_simple_workload_mode(cfg): |
| workload_path = cfg.opts("workload", "workload.path") |
| return SimpleWorkloadRepository(workload_path) |
| else: |
| return GitWorkloadRepository(cfg, fetch, update) |
| |
| |
| def data_dir(cfg, workload_name, corpus_name): |
| """ |
| Determines potential data directories for the provided workload and corpus name. |
| |
| :param cfg: The config object. |
| :param workload_name: Name of the current workload. |
| :param corpus_name: Name of the current corpus. |
| :return: A list containing either one or two elements. Each element contains a path to a directory which may contain document files. |
| """ |
| corpus_dir = os.path.join(cfg.opts("benchmarks", "local.dataset.cache"), corpus_name) |
| if is_simple_workload_mode(cfg): |
| workload_path = cfg.opts("workload", "workload.path") |
| r = SimpleWorkloadRepository(workload_path) |
| # data should always be stored in the workload's directory. If the user uses the same directory on all machines this will even work |
| # in the distributed case. However, the user is responsible to ensure that this is actually the case. |
| return [r.workload_dir(workload_name), corpus_dir] |
| else: |
| return [corpus_dir] |
| |
| |
| class GitWorkloadRepository: |
| def __init__(self, cfg, fetch, update, repo_class=repo.BenchmarkRepository): |
| # current workload name (if any) |
| self.workload_name = cfg.opts("workload", "workload.name", mandatory=False) |
| distribution_version = cfg.opts("builder", "distribution.version", mandatory=False) |
| repo_name = cfg.opts("workload", "repository.name") |
| repo_revision = cfg.opts("workload", "repository.revision", mandatory=False) |
| offline = cfg.opts("system", "offline.mode") |
| remote_url = cfg.opts("workloads", "%s.url" % repo_name, mandatory=False) |
| root = cfg.opts("node", "root.dir") |
| workload_repositories = cfg.opts("benchmarks", "workload.repository.dir") |
| workloads_dir = os.path.join(root, workload_repositories) |
| |
| self.repo = repo_class(remote_url, workloads_dir, repo_name, "workloads", offline, fetch) |
| if update: |
| if repo_revision: |
| self.repo.checkout(repo_revision) |
| else: |
| self.repo.update(distribution_version) |
| cfg.add(config.Scope.applicationOverride, "workload", "repository.revision", self.repo.revision) |
| |
| @property |
| def workload_names(self): |
| return filter(lambda p: os.path.exists(self.workload_file(p)), next(os.walk(self.repo.repo_dir))[1]) |
| |
| def workload_dir(self, workload_name): |
| return os.path.join(self.repo.repo_dir, workload_name) |
| |
| def workload_file(self, workload_name): |
| return os.path.join(self.workload_dir(workload_name), "workload.json") |
| |
| |
| class SimpleWorkloadRepository: |
| def __init__(self, workload_path): |
| if not os.path.exists(workload_path): |
| raise exceptions.SystemSetupError("Workload path %s does not exist" % workload_path) |
| |
| if os.path.isdir(workload_path): |
| self.workload_name = io.basename(workload_path) |
| self._workload_dir = workload_path |
| self._workload_file = os.path.join(workload_path, "workload.json") |
| if not os.path.exists(self._workload_file): |
| raise exceptions.SystemSetupError("Could not find workload.json in %s" % workload_path) |
| elif os.path.isfile(workload_path): |
| if io.has_extension(workload_path, ".json"): |
| self._workload_dir = io.dirname(workload_path) |
| self._workload_file = workload_path |
| self.workload_name = io.splitext(io.basename(workload_path))[0] |
| else: |
| raise exceptions.SystemSetupError("%s has to be a JSON file" % workload_path) |
| else: |
| raise exceptions.SystemSetupError("%s is neither a file nor a directory" % workload_path) |
| |
| @property |
| def workload_names(self): |
| return [self.workload_name] |
| |
| def workload_dir(self, workload_name): |
| assert workload_name == self.workload_name, "Expect provided workload name [%s] to match [%s]" % (workload_name, self.workload_name) |
| return self._workload_dir |
| |
| def workload_file(self, workload_name): |
| assert workload_name == self.workload_name |
| return self._workload_file |
| |
| |
| def operation_parameters(t, task): |
| op = task.operation |
| if op.param_source: |
| return params.param_source_for_name(op.param_source, t, op.params) |
| else: |
| return params.param_source_for_operation(op.type, t, op.params, task.name) |
| |
| |
| def used_corpora(t): |
| corpora = {} |
| if t.corpora: |
| test_procedure = t.selected_test_procedure_or_default |
| for task in test_procedure.schedule: |
| for sub_task in task: |
| param_source = operation_parameters(t, sub_task) |
| if hasattr(param_source, "corpora"): |
| for c in param_source.corpora: |
| # We might have the same corpus *but* they contain different doc sets. Therefore also need to union over doc sets. |
| corpora[c.name] = corpora.get(c.name, c).union(c) |
| return corpora.values() |
| |
| |
| class DefaultWorkloadPreparator(WorkloadProcessor): |
| def __init__(self, cfg): |
| super().__init__() |
| self.cfg = cfg |
| # just declare here, will be injected later |
| self.downloader = None |
| self.decompressor = None |
| self.workload = None |
| |
| @staticmethod |
| def prepare_docs(cfg, workload, corpus, preparator): |
| if corpus.streaming_ingestion: |
| return |
| for document_set in corpus.documents: |
| if document_set.is_supported_source_format: |
| data_root = data_dir(cfg, workload.name, corpus.name) |
| logging.getLogger(__name__).info("Resolved data root directory for document corpus [%s] in workload [%s] " |
| "to [%s].", corpus.name, workload.name, data_root) |
| if len(data_root) == 1: |
| preparator.prepare_document_set(document_set, data_root[0]) |
| # attempt to prepare everything in the current directory and fallback to the corpus directory |
| elif not preparator.prepare_bundled_document_set(document_set, data_root[0]): |
| preparator.prepare_document_set(document_set, data_root[1]) |
| |
| def on_prepare_workload(self, workload, data_root_dir): |
| prep = DocumentSetPreparator(workload.name, self.downloader, self.decompressor) |
| for corpus in used_corpora(workload): |
| params = { |
| "cfg": self.cfg, |
| "workload": workload, |
| "corpus": corpus, |
| "preparator": prep |
| } |
| yield DefaultWorkloadPreparator.prepare_docs, params |
| |
| |
| class Decompressor: |
| def __init__(self): |
| self.logger = logging.getLogger(__name__) |
| |
| def decompress(self, archive_path, documents_path, uncompressed_size): |
| if uncompressed_size: |
| msg = f"Decompressing workload data from [{archive_path}] to [{documents_path}] (resulting size: " \ |
| f"[{convert.bytes_to_gb(uncompressed_size):.2f}] GB) ... " |
| else: |
| msg = f"Decompressing workload data from [{archive_path}] to [{documents_path}] ... " |
| |
| console.info(msg, end="", flush=True, logger=self.logger) |
| io.decompress(archive_path, io.dirname(archive_path)) |
| console.println("[OK]") |
| if not os.path.isfile(documents_path): |
| raise exceptions.DataError( |
| f"Decompressing [{archive_path}] did not create [{documents_path}]. Please check with the workload " |
| f"author if the compressed archive has been created correctly.") |
| |
| extracted_bytes = os.path.getsize(documents_path) |
| if uncompressed_size is not None and extracted_bytes != uncompressed_size: |
| raise exceptions.DataError(f"[{documents_path}] is corrupt. Extracted [{extracted_bytes}] bytes " |
| f"but [{uncompressed_size}] bytes are expected.") |
| |
| |
| class Downloader: |
| def __init__(self, offline, test_mode): |
| self.offline = offline |
| self.test_mode = test_mode |
| self.logger = logging.getLogger(__name__) |
| |
| def download(self, base_url, source_url, target_path, size_in_bytes): |
| file_name = os.path.basename(target_path) |
| |
| if not base_url: |
| raise exceptions.DataError("Cannot download data because no base URL is provided.") |
| if self.offline: |
| raise exceptions.SystemSetupError(f"Cannot find [{target_path}]. Please disable offline mode and retry.") |
| |
| if source_url: |
| data_url = source_url |
| else: |
| if base_url.endswith("/"): |
| separator = "" |
| else: |
| separator = "/" |
| # join manually as `urllib.parse.urljoin` does not work with S3 or GS URL schemes. |
| data_url = f"{base_url}{separator}{file_name}" |
| try: |
| io.ensure_dir(os.path.dirname(target_path)) |
| if size_in_bytes: |
| size_in_mb = round(convert.bytes_to_mb(size_in_bytes)) |
| self.logger.info("Downloading data from [%s] (%s MB) to [%s].", data_url, size_in_mb, target_path) |
| else: |
| self.logger.info("Downloading data from [%s] to [%s].", data_url, target_path) |
| |
| # we want to have a bit more accurate download progress as these files are typically very large |
| progress = net.Progress("[INFO] Downloading workload data file: " + os.path.basename(target_path), |
| accuracy=1) |
| net.download(data_url, target_path, size_in_bytes, progress_indicator=progress) |
| progress.finish() |
| self.logger.info("Downloaded data from [%s] to [%s].", data_url, target_path) |
| except urllib.error.HTTPError as e: |
| if e.code == 404 and self.test_mode: |
| raise exceptions.DataError("This workload does not support test mode. Ask the workload author to add it or" |
| " disable test mode and retry.") from None |
| else: |
| msg = f"Could not download [{data_url}] to [{target_path}]" |
| if e.reason: |
| msg += f" (HTTP status: {e.code}, reason: {e.reason})" |
| else: |
| msg += f" (HTTP status: {e.code})" |
| raise exceptions.DataError(msg, e) from None |
| except urllib.error.URLError as e: |
| raise exceptions.DataError(f"Could not download [{data_url}] to [{target_path}].") from e |
| |
| if not os.path.isfile(target_path): |
| raise exceptions.SystemSetupError(f"Could not download [{data_url}] to [{target_path}]. Verify data " |
| f"are available at [{data_url}] and check your Internet connection.") |
| |
| actual_size = os.path.getsize(target_path) |
| if size_in_bytes is not None and actual_size != size_in_bytes: |
| raise exceptions.DataError(f"[{target_path}] is corrupt. Downloaded [{actual_size}] bytes " |
| f"but [{size_in_bytes}] bytes are expected.") |
| |
| |
| class DocumentSetPreparator: |
| def __init__(self, workload_name, downloader, decompressor): |
| self.workload_name = workload_name |
| self.downloader = downloader |
| self.decompressor = decompressor |
| self.logger = logging.getLogger(__name__) |
| |
| def is_locally_available(self, file_name): |
| return os.path.isfile(file_name) |
| |
| def has_expected_size(self, file_name, expected_size): |
| return expected_size is None or os.path.getsize(file_name) == expected_size |
| |
| def create_file_offset_table(self, document_file_path, base_url, source_url, expected_number_of_lines): |
| # just rebuild the file every time for the time being. Later on, we might check the data file fingerprint to avoid it |
| lines_read = io.prepare_file_offset_table(document_file_path, base_url, source_url, self.downloader) |
| if lines_read and lines_read != expected_number_of_lines: |
| io.remove_file_offset_table(document_file_path) |
| raise exceptions.DataError(f"Data in [{document_file_path}] for workload [{self.workload_name}] are invalid. " |
| f"Expected [{expected_number_of_lines}] lines but got [{lines_read}].") |
| |
| def prepare_document_set(self, document_set, data_root): |
| """ |
| Prepares a document set locally. |
| |
| Precondition: The document set contains either a compressed or an uncompressed document file reference. |
| Postcondition: Either following files will be present locally: |
| |
| * The compressed document file (if specified originally in the corpus) |
| * The uncompressed document file |
| * A file offset table based on the document file |
| |
| Or this method will raise an appropriate Exception (download error, inappropriate specification of files, ...). |
| |
| :param document_set: A document set. |
| :param data_root: The data root directory for this document set. |
| """ |
| doc_path = os.path.join(data_root, document_set.document_file) |
| archive_path = os.path.join(data_root, document_set.document_archive) if document_set.has_compressed_corpus() else None |
| while True: |
| if self.is_locally_available(doc_path) and \ |
| self.has_expected_size(doc_path, document_set.uncompressed_size_in_bytes): |
| break |
| if document_set.has_compressed_corpus() and \ |
| self.is_locally_available(archive_path) and \ |
| self.has_expected_size(archive_path, document_set.compressed_size_in_bytes): |
| self.decompressor.decompress(archive_path, doc_path, document_set.uncompressed_size_in_bytes) |
| else: |
| if document_set.has_compressed_corpus(): |
| target_path = archive_path |
| expected_size = document_set.compressed_size_in_bytes |
| elif document_set.has_uncompressed_corpus(): |
| target_path = doc_path |
| expected_size = document_set.uncompressed_size_in_bytes |
| else: |
| # this should not happen in practice as the JSON schema should take care of this |
| raise exceptions.BenchmarkAssertionError(f"Workload {self.workload_name} specifies documents but no corpus") |
| |
| try: |
| if document_set.document_file_parts: |
| for part in document_set.document_file_parts: |
| self.downloader.download(document_set.base_url, None, os.path.join(data_root, part["name"]), part["size"]) |
| try: |
| with open(target_path, "wb") as outfile: |
| console.info(f"Concatenating file parts {', '.join([p['name'] for p in document_set.document_file_parts])}" |
| f" into {os.path.basename(target_path)}", flush=True, logger=self.logger) |
| for part in document_set.document_file_parts: |
| part_name = os.path.join(data_root, part["name"]) |
| with open(part_name, "rb") as infile: |
| shutil.copyfileobj(infile, outfile) |
| os.remove(part_name) |
| except Exception as e: |
| raise exceptions.DataError(f"Encountered exception {repr(e)} when building corpus file from parts") |
| else: |
| self.downloader.download(document_set.base_url, document_set.source_url, target_path, expected_size) |
| except exceptions.DataError as e: |
| if e.message == "Cannot download data because no base URL is provided." and \ |
| self.is_locally_available(target_path): |
| raise exceptions.DataError(f"[{target_path}] is present but does not have the expected " |
| f"size of [{expected_size}] bytes and it cannot be downloaded " |
| f"because no base URL is provided.") from None |
| else: |
| raise |
| if document_set.support_file_offset_table: |
| self.create_file_offset_table(doc_path, document_set.base_url, document_set.source_url, document_set.number_of_lines) |
| |
| def prepare_bundled_document_set(self, document_set, data_root): |
| """ |
| Prepares a document set that comes "bundled" with the workload, i.e. the data files are in the same directory as the workload. |
| This is a "lightweight" version of #prepare_document_set() which assumes that at least one file is already present in the |
| current directory. It will attempt to find the appropriate files, decompress if necessary and create a file offset table. |
| |
| Precondition: The document set contains either a compressed or an uncompressed document file reference. |
| Postcondition: If this method returns ``True``, the following files will be present locally: |
| |
| * The compressed document file (if specified originally in the corpus) |
| * The uncompressed document file |
| * A file offset table based on the document file |
| |
| If this method returns ``False`` either the document size is wrong or any files have not been found. |
| |
| :param document_set: A document set. |
| :param data_root: The data root directory for this document set (should be the same as the workload file). |
| :return: See postcondition. |
| """ |
| doc_path = os.path.join(data_root, document_set.document_file) |
| archive_path = os.path.join(data_root, document_set.document_archive) if document_set.has_compressed_corpus() else None |
| |
| while True: |
| if self.is_locally_available(doc_path): |
| if self.has_expected_size(doc_path, document_set.uncompressed_size_in_bytes): |
| self.create_file_offset_table(doc_path, document_set.base_url, document_set.source_url, document_set.number_of_lines) |
| return True |
| else: |
| raise exceptions.DataError(f"[{doc_path}] is present but does not have the expected size " |
| f"of [{document_set.uncompressed_size_in_bytes}] bytes.") |
| |
| if document_set.has_compressed_corpus() and self.is_locally_available(archive_path): |
| if self.has_expected_size(archive_path, document_set.compressed_size_in_bytes): |
| self.decompressor.decompress(archive_path, doc_path, document_set.uncompressed_size_in_bytes) |
| else: |
| # treat this is an error because if the file is present but the size does not match, something is |
| # really fishy. It is likely that the user is currently creating a new workload and did not specify |
| # the file size correctly. |
| raise exceptions.DataError(f"[{archive_path}] is present but does not have " |
| f"the expected size of [{document_set.compressed_size_in_bytes}] bytes.") |
| else: |
| return False |
| |
| |
| class TemplateSource: |
| """ |
| Prepares the fully assembled workload file from file or string. |
| Doesn't render using jinja2, but embeds workload fragments referenced with |
| benchmark.collect(parts=... |
| """ |
| |
| collect_parts_re = re.compile(r"{{\ +?benchmark\.collect\(parts=\"(.+?(?=\"))\"\)\ +?}}") |
| |
| def __init__(self, base_path, template_file_name, source=io.FileSource, fileglobber=glob.glob): |
| self.base_path = base_path |
| self.template_file_name = template_file_name |
| self.source = source |
| self.fileglobber = fileglobber |
| self.assembled_source = None |
| self.logger = logging.getLogger(__name__) |
| |
| def load_template_from_file(self): |
| loader = jinja2.FileSystemLoader(self.base_path) |
| try: |
| base_workload = loader.get_source(jinja2.Environment( |
| autoescape=select_autoescape(['html', 'xml'])), |
| self.template_file_name) |
| except jinja2.TemplateNotFound: |
| self.logger.exception("Could not load workload from [%s].", self.template_file_name) |
| raise WorkloadSyntaxError("Could not load workload from '{}'".format(self.template_file_name)) |
| self.assembled_source = self.replace_includes(self.base_path, base_workload[0]) |
| |
| def load_template_from_string(self, template_source): |
| self.assembled_source = self.replace_includes(self.base_path, template_source) |
| |
| def replace_includes(self, base_path, workload_fragment): |
| match = TemplateSource.collect_parts_re.findall(workload_fragment) |
| if match: |
| # Construct replacement dict for matched captures |
| repl = {} |
| for glob_pattern in match: |
| full_glob_path = os.path.join(base_path, glob_pattern) |
| sub_source = self.read_glob_files(full_glob_path) |
| repl[glob_pattern] = self.replace_includes(base_path=io.dirname(full_glob_path), workload_fragment=sub_source) |
| |
| def replstring(matchobj): |
| # matchobj.groups() is a tuple and first element contains the matched group id |
| return repl[matchobj.groups()[0]] |
| |
| return TemplateSource.collect_parts_re.sub(replstring, workload_fragment) |
| return workload_fragment |
| |
| def read_glob_files(self, pattern): |
| source = [] |
| files = self.fileglobber(pattern) |
| for fname in files: |
| with self.source(fname, mode="rt", encoding="utf-8") as fp: |
| source.append(fp.read()) |
| return ",\n".join(source) |
| |
| |
| # A Jinja filter that tests if a version string lies within a specified range. |
| # For instance, "1.2.3" lies between "1.0.0" and "2.0.0". |
| def version_between(version, frm, to): |
| return list(map(int, version.split('.'))) >= list(map(int, frm.split('.'))) and \ |
| list(map(int, version.split('.'))) <= list(map(int, to.split('.'))) |
| |
| |
| def default_internal_template_vars(glob_helper=lambda f: [], clock=time.Clock): |
| """ |
| Dict of internal global variables used by our jinja2 renderers |
| """ |
| |
| return { |
| "globals": { |
| "now": clock.now(), |
| "glob": glob_helper |
| }, |
| "filters": { |
| "days_ago": time.days_ago |
| } |
| } |
| |
| |
| def render_template(template_source, template_vars=None, template_internal_vars=None, loader=None): |
| macros = [ |
| """ |
| {% macro collect(parts) -%} |
| {% set comma = joiner() %} |
| {% for part in glob(parts) %} |
| {{ comma() }} |
| {% include part %} |
| {% endfor %} |
| {%- endmacro %} |
| """, |
| """ |
| {% macro exists_set_param(setting_name, value, default_value=None, comma=True) -%} |
| {% if value is defined or default_value is not none %} |
| {% if comma %} , {% endif %} |
| {% if default_value is not none %} |
| "{{ setting_name }}": {{ value | default(default_value) | tojson }} |
| {% else %} |
| "{{ setting_name }}": {{ value | tojson }} |
| {% endif %} |
| {% endif %} |
| {%- endmacro %} |
| """ |
| ] |
| |
| # place helpers dict loader first to prevent users from overriding our macros. |
| env = jinja2.Environment( |
| loader=jinja2.ChoiceLoader([ |
| jinja2.DictLoader({"benchmark.helpers": "".join(macros)}), |
| jinja2.BaseLoader(), |
| loader |
| ]), |
| autoescape=select_autoescape(['html', 'xml']) |
| ) |
| |
| if template_vars: |
| for k, v in template_vars.items(): |
| env.globals[k] = v |
| # ensure that user variables never override our internal variables |
| if template_internal_vars: |
| for macro_type in template_internal_vars: |
| for env_global_key, env_global_value in template_internal_vars[macro_type].items(): |
| getattr(env, macro_type)[env_global_key] = env_global_value |
| |
| env.filters["version_between"] = version_between |
| template = env.from_string(template_source) |
| return template.render() |
| |
| |
| def register_all_params_in_workload(assembled_source, complete_workload_params=None): |
| j2env = jinja2.Environment(autoescape=select_autoescape(['html', 'xml'])) |
| |
| # we don't need the following j2 filters/macros but we define them anyway to prevent parsing failures |
| internal_template_vars = default_internal_template_vars() |
| for macro_type in internal_template_vars: |
| for env_global_key, env_global_value in internal_template_vars[macro_type].items(): |
| getattr(j2env, macro_type)[env_global_key] = env_global_value |
| |
| ast = j2env.parse(assembled_source) |
| j2_variables = meta.find_undeclared_variables(ast) |
| if complete_workload_params: |
| complete_workload_params.populate_workload_defined_params(j2_variables) |
| |
| |
| def render_template_from_file(template_file_name, template_vars, complete_workload_params=None): |
| def relative_glob(start, f): |
| result = glob.glob(os.path.join(start, f)) |
| if result: |
| return [os.path.relpath(p, start) for p in result] |
| else: |
| return [] |
| |
| base_path = io.dirname(template_file_name) |
| template_source = TemplateSource(base_path, io.basename(template_file_name)) |
| template_source.load_template_from_file() |
| register_all_params_in_workload(template_source.assembled_source, complete_workload_params) |
| |
| return render_template(loader=jinja2.FileSystemLoader(base_path), |
| template_source=template_source.assembled_source, |
| template_vars=template_vars, |
| template_internal_vars=default_internal_template_vars(glob_helper=lambda f: relative_glob(base_path, f))) |
| |
| |
| class TaskFilterWorkloadProcessor(WorkloadProcessor): |
| def __init__(self, cfg): |
| self.logger = logging.getLogger(__name__) |
| include_tasks = cfg.opts("workload", "include.tasks", mandatory=False) |
| exclude_tasks = cfg.opts("workload", "exclude.tasks", mandatory=False) |
| |
| if include_tasks: |
| filtered_tasks = include_tasks |
| self.exclude = False |
| else: |
| filtered_tasks = exclude_tasks |
| self.exclude = True |
| self.filters = self._filters_from_filtered_tasks(filtered_tasks) |
| |
| def _filters_from_filtered_tasks(self, filtered_tasks): |
| filters = [] |
| if filtered_tasks: |
| for t in filtered_tasks: |
| spec = t.split(":") |
| if len(spec) == 1: |
| filters.append(workload.TaskNameFilter(spec[0])) |
| elif len(spec) == 2: |
| if spec[0] == "type": |
| filters.append(workload.TaskOpTypeFilter(spec[1])) |
| elif spec[0] == "tag": |
| filters.append(workload.TaskTagFilter(spec[1])) |
| else: |
| raise exceptions.SystemSetupError(f"Invalid format for filtered tasks: [{t}]. " |
| f"Expected [type] but got [{spec[0]}].") |
| else: |
| raise exceptions.SystemSetupError(f"Invalid format for filtered tasks: [{t}]") |
| return filters |
| |
| def _filter_out_match(self, task): |
| for f in self.filters: |
| if task.matches(f): |
| if hasattr(task, "tasks") and self.exclude: |
| return False |
| return self.exclude |
| return not self.exclude |
| |
| def on_after_load_workload(self, input_workload, **kwargs): |
| if not self.filters: |
| return input_workload |
| |
| for test_procedure in input_workload.test_procedures: |
| # don't modify the schedule while iterating over it |
| tasks_to_remove = [] |
| for task in test_procedure.schedule: |
| if self._filter_out_match(task): |
| tasks_to_remove.append(task) |
| else: |
| leafs_to_remove = [] |
| for leaf_task in task: |
| if self._filter_out_match(leaf_task): |
| leafs_to_remove.append(leaf_task) |
| for leaf_task in leafs_to_remove: |
| self.logger.info("Removing sub-task [%s] from test_procedure [%s] due to task filter.", |
| leaf_task, test_procedure) |
| task.remove_task(leaf_task) |
| for task in tasks_to_remove: |
| self.logger.info("Removing task [%s] from test_procedure [%s] due to task filter.", task, test_procedure) |
| test_procedure.remove_task(task) |
| |
| return input_workload |
| |
| |
| class TestModeWorkloadProcessor(WorkloadProcessor): |
| def __init__(self, cfg): |
| self.test_mode_enabled = cfg.opts("workload", "test.mode.enabled", mandatory=False, default_value=False) |
| self.logger = logging.getLogger(__name__) |
| |
| def on_after_load_workload(self, input_workload, **kwargs): |
| if not self.test_mode_enabled: |
| return input_workload |
| self.logger.info("Preparing workload [%s] for test mode.", str(input_workload)) |
| for corpus in input_workload.corpora: |
| if self.logger.isEnabledFor(logging.DEBUG): |
| self.logger.debug("Reducing corpus size to 1000 documents for [%s]", corpus.name) |
| for document_set in corpus.documents: |
| # TODO #341: Should we allow this for snapshots too? |
| if document_set.is_bulk: |
| document_set.number_of_documents = 1000 |
| |
| if document_set.has_compressed_corpus(): |
| path, ext = io.splitext(document_set.document_archive) |
| path_2, ext_2 = io.splitext(path) |
| |
| document_set.document_archive = f"{path_2}-1k{ext_2}{ext}" |
| document_set.document_file = f"{path_2}-1k{ext_2}" |
| elif document_set.has_uncompressed_corpus(): |
| path, ext = io.splitext(document_set.document_file) |
| document_set.document_file = f"{path}-1k{ext}" |
| else: |
| raise exceptions.BenchmarkAssertionError(f"Document corpus [{corpus.name}] has neither compressed " |
| f"nor uncompressed corpus.") |
| |
| # we don't want to check sizes |
| document_set.compressed_size_in_bytes = None |
| document_set.uncompressed_size_in_bytes = None |
| |
| for test_procedure in input_workload.test_procedures: |
| for task in test_procedure.schedule: |
| # we need iterate over leaf tasks and await iterating over possible intermediate 'parallel' elements |
| for leaf_task in task: |
| # iteration-based schedules are divided among all clients and we should provide |
| # at least one iteration for each client. |
| if leaf_task.warmup_iterations is not None and leaf_task.warmup_iterations > leaf_task.clients: |
| count = leaf_task.clients |
| if self.logger.isEnabledFor(logging.DEBUG): |
| self.logger.debug("Resetting warmup iterations to %d for [%s]", count, str(leaf_task)) |
| leaf_task.warmup_iterations = count |
| if leaf_task.iterations is not None and leaf_task.iterations > leaf_task.clients: |
| count = leaf_task.clients |
| if self.logger.isEnabledFor(logging.DEBUG): |
| self.logger.debug("Resetting measurement iterations to %d for [%s]", count, str(leaf_task)) |
| leaf_task.iterations = count |
| if leaf_task.warmup_time_period is not None and leaf_task.warmup_time_period > 0: |
| leaf_task.warmup_time_period = 0 |
| if self.logger.isEnabledFor(logging.DEBUG): |
| self.logger.debug("Resetting warmup time period for [%s] to [%d] seconds.", |
| str(leaf_task), leaf_task.warmup_time_period) |
| if leaf_task.time_period is not None and leaf_task.time_period > 10: |
| leaf_task.time_period = 10 |
| if self.logger.isEnabledFor(logging.DEBUG): |
| self.logger.debug("Resetting measurement time period for [%s] to [%d] seconds.", |
| str(leaf_task), leaf_task.time_period) |
| |
| # Keep throttled to expose any errors but increase the target throughput for short execution times. |
| if leaf_task.target_throughput: |
| original_throughput = leaf_task.target_throughput |
| leaf_task.params.pop("target-throughput", None) |
| leaf_task.params.pop("target-interval", None) |
| leaf_task.params["target-throughput"] = f"{sys.maxsize} {original_throughput.unit}" |
| |
| return input_workload |
| |
| class QueryRandomizerWorkloadProcessor(WorkloadProcessor): |
| |
| class QueryRandomizationInfo: |
| # A class containing information about which values to replace when randomizing queries. |
| # For example, QueryRandomizationInfo("range", [["gte", "gt"], ["lte", "lt"]], ["format"]) |
| # would find queries using the "range" keyword. |
| # Then, it would look for parameters named either "gt" or "gte", and "lt" or "lte" in the "range" object. |
| # It would also look for the optional parameter "format". |
| # The values pointed to by "gt"/"gte" and "lt"/"lte" would be randomized according to the standard value source. |
| # "format" would also be modified by the standard value source, if it was present in the original query. |
| # The first option for each set of options must match the name provided by the standard value source in workload.py. |
| def __init__(self, query_name, parameter_name_options_list, optional_parameters): |
| self.query_name = query_name |
| self.validate_parameter_name_options_list(query_name, parameter_name_options_list) |
| self.parameter_name_options_list = parameter_name_options_list |
| self.optional_parameters = optional_parameters |
| |
| def validate_parameter_name_options_list(self, query_name, parameter_name_options_list): |
| # Check there are no duplicate values as this would cause ambiguity |
| all_values = [] |
| distinct_values = set() |
| for parameter_name_options in parameter_name_options_list: |
| for parameter_name_option in parameter_name_options: |
| if parameter_name_option == query_name: |
| raise exceptions.ExecutorError( |
| f"Cannot have a randomized value name {query_name} which is the same as the name of its query!") |
| all_values.append(parameter_name_option) |
| distinct_values.add(parameter_name_option) |
| if len(all_values) != len(distinct_values): |
| raise exceptions.ExecutorError( |
| f"Duplicate option for value name in query_randomization_info: {parameter_name_options_list}") |
| |
| def check_one_of_each_name_present(self, obj): |
| # Return true if one version of the value name is present in obj for each set of value options. |
| # For example, QueryRandomizationInfo("range", [["gt", "gte"], ["lt", "lte"]]) |
| # would return true if both "gte" and "lt" were present. |
| for parameter_name_options in self.parameter_name_options_list: |
| option_present = False |
| for name_option in parameter_name_options: |
| if name_option in obj: |
| option_present = True |
| break |
| if not option_present: |
| return False |
| return True |
| |
| DEFAULT_RF = 0.3 |
| DEFAULT_N = 5000 |
| DEFAULT_ALPHA = 1 |
| DEFAULT_QUERY_RANDOMIZATION_INFO = QueryRandomizationInfo("range", [["gte", "gt"], ["lte", "lt"]], ["format"]) |
| def __init__(self, cfg): |
| self.randomization_enabled = cfg.opts("workload", "randomization.enabled", mandatory=False, default_value=False) |
| self.rf = float(cfg.opts("workload", "randomization.repeat_frequency", mandatory=False, default_value=self.DEFAULT_RF)) |
| self.logger = logging.getLogger(__name__) |
| self.N = int(cfg.opts("workload", "randomization.n", mandatory=False, default_value=self.DEFAULT_N)) |
| self.zipf_alpha = float(cfg.opts("workload", "randomization.alpha", mandatory=False, default_value=self.DEFAULT_ALPHA)) |
| self.H_list = self.precompute_H(self.N, self.zipf_alpha) |
| |
| # Helper functions for computing Zipf distribution |
| def H(self, i, H_list): |
| # compute the harmonic number H_n,m = sum over i from 1 to n of (1 / i^m) |
| return H_list[i-1] |
| |
| def precompute_H(self, n, m): |
| H_list = [1] |
| for j in range(2, n+1): |
| H_list.append(H_list[-1] + 1 / (j ** m)) |
| return H_list |
| |
| def zipf_cdf_inverse(self, u, H_list): |
| # To map a uniformly distributed u from [0, 1] to some probability distribution we plug it into its inverse CDF. |
| # as the zipf cdf is discontinuous there is no real inverse but we can use this solution: |
| # https://math.stackexchange.com/questions/53671/how-to-calculate-the-inverse-cdf-for-the-zipf-distribution |
| # Precompute all values H_i,alpha for a fixed alpha and pass in as H_list |
| if (u < 0 or u >= 1): |
| raise exceptions.ExecutorError( |
| "Input u must have 0 <= u < 1. This error shouldn't appear, please raise an issue if it does") |
| n = len(H_list) |
| candidate_return = 1 |
| denominator = self.H(n, H_list) |
| numerator = 0 |
| while candidate_return < n: |
| numerator = self.H(candidate_return, H_list) |
| if u < numerator / denominator: |
| return candidate_return |
| candidate_return += 1 |
| return n |
| |
| def get_dict_from_previous_path(self, root, current_path): |
| curr = root |
| for value in current_path: |
| curr = curr[value] |
| return curr |
| |
| def extract_fields_helper(self, root, current_path, query_randomization_info): |
| # Recursively called to find the location of ranges in a range query. |
| # Return the field and the current path if we're currently scanning the field name in a range query, otherwise return an empty list. |
| fields = [] # pairs of (field, path_to_field) |
| curr = self.get_dict_from_previous_path(root, current_path) |
| if isinstance(curr, dict) and curr != {}: |
| if len(current_path) > 0 and current_path[-1] == query_randomization_info.query_name: |
| for key in curr.keys(): |
| if isinstance(curr, dict): |
| if query_randomization_info.check_one_of_each_name_present(curr[key]): |
| fields.append((key, current_path)) |
| return fields |
| else: |
| for key in curr.keys(): |
| fields += self.extract_fields_helper(root, current_path + [key], query_randomization_info) |
| return fields |
| elif isinstance(curr, list) and curr != []: |
| for i in range(len(curr)): |
| fields += self.extract_fields_helper(root, current_path + [i], query_randomization_info) |
| return fields |
| else: |
| # leaf node |
| return [] |
| |
| def extract_fields_and_paths(self, params, query_randomization_info): |
| # Search for fields used in range queries, and the paths to those fields |
| # Return pairs of (field, path_to_field) |
| # TODO: Maybe only do this the first time, and assume for a given task, the same query structure is used. |
| # We could achieve this by passing in the task name to get_randomized_values as a kwarg? |
| try: |
| root = params["body"]["query"] |
| except KeyError: |
| raise exceptions.SystemSetupError( |
| f"Cannot extract range query fields from these params: {params}\n, missing params[\"body\"][\"query\"]\n" |
| f"Make sure the operation in operations/default.json is well-formed") |
| fields_and_paths = self.extract_fields_helper(root, [], query_randomization_info) |
| return fields_and_paths |
| |
| def set_range(self, params, fields_and_paths, new_values, query_randomization_info): |
| assert len(fields_and_paths) == len(new_values) |
| for field_and_path, new_value in zip(fields_and_paths, new_values): |
| field = field_and_path[0] |
| path = field_and_path[1] |
| range_section = self.get_dict_from_previous_path(params["body"]["query"], path)[field] |
| # get the section of the query corresponding to the field name |
| for parameter_name_options in query_randomization_info.parameter_name_options_list: |
| for option in parameter_name_options: |
| if option in range_section: |
| range_section[option] = new_value[parameter_name_options[0]] |
| for optional_parameter in query_randomization_info.optional_parameters: |
| if optional_parameter in new_values: |
| range_section[optional_parameter] = new_values[optional_parameter] |
| return params |
| |
| def get_repeated_value_index(self): |
| # minus 1 for mapping [1, N] to [0, N-1] of list indices |
| return self.zipf_cdf_inverse(random.random(), self.H_list) - 1 |
| |
| def get_randomized_values(self, input_workload, input_params, query_randomization_info, |
| get_standard_value=params.get_standard_value, |
| get_standard_value_source=params.get_standard_value_source, # Made these configurable for simpler unit tests |
| **kwargs): |
| # The queries as listed in operations/default.json don't have the index param, |
| # unlike the custom ones you would specify in workload.py, so we have to add them ourselves |
| if "index" not in input_params: |
| input_params["index"] = params.get_target(input_workload, input_params) |
| |
| fields_and_paths = self.extract_fields_and_paths(input_params, query_randomization_info) |
| |
| if random.random() < self.rf: |
| # Draw a potentially repeated value from the saved standard values |
| index = self.get_repeated_value_index() |
| new_values = [get_standard_value(kwargs["op_name"], field_and_path[0], index) for field_and_path in fields_and_paths] |
| # Use the same index for all fields in one query, otherwise the probability of repeats in a multi-field query would be very low |
| input_params = self.set_range(input_params, fields_and_paths, new_values, query_randomization_info) |
| else: |
| # Generate a new random value, from the standard value source function. This will be new (a cache miss) |
| new_values = [get_standard_value_source(kwargs["op_name"], field_and_path[0])() for field_and_path in fields_and_paths] |
| input_params = self.set_range(input_params, fields_and_paths, new_values, query_randomization_info) |
| return input_params |
| |
| def create_param_source_lambda(self, op_name, get_standard_value, get_standard_value_source, get_query_randomization_info): |
| return lambda w, p, **kwargs: self.get_randomized_values(w, p, query_randomization_info=get_query_randomization_info(op_name), |
| get_standard_value=get_standard_value, |
| get_standard_value_source=get_standard_value_source, |
| op_name=op_name, **kwargs) |
| |
| def on_after_load_workload(self, input_workload, **kwargs): |
| if not self.randomization_enabled: |
| self.logger.info("Query randomization is disabled.") |
| return input_workload |
| self.logger.info("Query randomization is enabled, with repeat frequency = %d, n = %d",self.rf, self.N) |
| |
| # By default, use params for standard values and generate new standard values the first time an op/field is seen. |
| # In unit tests, we should be able to supply our own sources independent of params. |
| # This is done in kwargs because pylint didn't like having specific keyword args that weren't in the parent method. |
| generate_new_standard_values = False |
| if "get_standard_value" not in kwargs: |
| kwargs["get_standard_value"] = params.get_standard_value |
| generate_new_standard_values = True |
| if "get_standard_value_source" not in kwargs: |
| kwargs["get_standard_value_source"] = params.get_standard_value_source |
| generate_new_standard_values = True |
| |
| default_test_procedure = None |
| for test_procedure in input_workload.test_procedures: |
| if test_procedure.default: |
| default_test_procedure = test_procedure |
| break |
| |
| for task in default_test_procedure.schedule: |
| for leaf_task in task: |
| try: |
| op_type = workload.OperationType.from_hyphenated_string(leaf_task.operation.type) |
| except KeyError: |
| op_type = None |
| self.logger.info( |
| "Found operation %s in default schedule with type %s, which couldn't be converted to a known OperationType", |
| leaf_task.operation.name, leaf_task.operation.type) |
| if op_type == workload.OperationType.Search: |
| op_name = leaf_task.operation.name |
| param_source_name = op_name + "-randomized" |
| params.register_param_source_for_name( |
| param_source_name, |
| self.create_param_source_lambda(op_name, get_standard_value=kwargs["get_standard_value"], |
| get_standard_value_source=kwargs["get_standard_value_source"], |
| get_query_randomization_info=params.get_query_randomization_info)) |
| leaf_task.operation.param_source = param_source_name |
| # Generate the right number of standard values for this field, if not already present |
| for field_and_path in self.extract_fields_and_paths(leaf_task.operation.params, |
| params.get_query_randomization_info(op_name)): |
| if generate_new_standard_values: |
| params.generate_standard_values_if_absent(op_name, field_and_path[0], self.N) |
| return input_workload |
| |
| class CompleteWorkloadParams: |
| def __init__(self, user_specified_workload_params=None): |
| self.workload_defined_params = set() |
| self.user_specified_workload_params = user_specified_workload_params if user_specified_workload_params else {} |
| |
| def populate_workload_defined_params(self, list_of_workload_params=None): |
| self.workload_defined_params.update(set(list_of_workload_params)) |
| |
| @property |
| def sorted_workload_defined_params(self): |
| return sorted(self.workload_defined_params) |
| |
| def unused_user_defined_workload_params(self): |
| set_user_params = set(list(self.user_specified_workload_params.keys())) |
| set_user_params.difference_update(self.workload_defined_params) |
| |
| return list(set_user_params) |
| |
| |
| class WorkloadFileReader: |
| MINIMUM_SUPPORTED_TRACK_VERSION = 2 |
| MAXIMUM_SUPPORTED_TRACK_VERSION = 2 |
| COMMON_WORKLOAD_FORMAT_ERRORS = """ |
| --------------------------------------------------------------------------------------------------------------------------- |
| [Common workload formatting errors:] \n |
| - Jinja2 expression missing parameters (e.g. got {{search_clients}} but needs {{search_clients | default(8)}})\n |
| - Jinja2 expression missing \"tojson\" parameter when needed(e.g. got {{index_settings | default({})}} but needs {{index_settings | default({}) | tojson}})\n |
| - JSON file might not be correctly formatted after rendering Jinja2 (e.g. additional brackets (}, ]) or missing commas (,)) |
| --------------------------------------------------------------------------------------------------------------------------- |
| """ |
| |
| """ |
| Creates a workload from a workload file. |
| """ |
| |
| def __init__(self, cfg): |
| workload_schema_file = os.path.join(cfg.opts("node", "benchmark.root"), "resources", "workload-schema.json") |
| with open(workload_schema_file, mode="rt", encoding="utf-8") as f: |
| self.workload_schema = json.loads(f.read()) |
| self.workload_params = cfg.opts("workload", "params", mandatory=False) |
| self.complete_workload_params = CompleteWorkloadParams(user_specified_workload_params=self.workload_params) |
| self.read_workload = WorkloadSpecificationReader( |
| workload_params=self.workload_params, |
| complete_workload_params=self.complete_workload_params, |
| selected_test_procedure=cfg.opts("workload", "test_procedure.name", mandatory=False) |
| ) |
| self.logger = logging.getLogger(__name__) |
| |
| def read(self, workload_name, workload_spec_file, mapping_dir): |
| """ |
| Reads a workload file, verifies it against the JSON schema and if valid, creates a workload. |
| |
| :param workload_name: The name of the workload. |
| :param workload_spec_file: The complete path to the workload specification file. |
| :param mapping_dir: The directory where the mapping files for this workload are stored locally. |
| :return: A corresponding workload instance if the workload file is valid. |
| """ |
| |
| self.logger.info("Reading workload specification file [%s].", workload_spec_file) |
| # render the workload to a temporary file instead of dumping it into the logs. It is easier to check for error messages |
| # involving lines numbers and it also does not bloat Solr Orbit's log file so much. |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json") |
| try: |
| rendered = render_template_from_file( |
| workload_spec_file, self.workload_params, |
| complete_workload_params=self.complete_workload_params) |
| with open(tmp.name, "wt", encoding="utf-8") as f: |
| f.write(rendered) |
| self.logger.info("Final rendered workload for '%s' has been written to '%s'.", workload_spec_file, tmp.name) |
| workload_spec = json.loads(rendered) |
| |
| except jinja2.exceptions.TemplateNotFound: |
| self.logger.exception("Could not load [%s]", workload_spec_file) |
| raise exceptions.SystemSetupError("Workload {} does not exist".format(workload_name)) |
| |
| except jinja2.exceptions.TemplateSyntaxError as e: |
| exception_message = f"Jinja2 Exception TemplateSyntaxError: {e}\n" |
| if 'endif' in exception_message: |
| exception_message = exception_message + \ |
| "There is an extra Jinja2 \"endif\" somewhere in the workload's files. " + \ |
| "Please remove it so that the workload can be rendered and run.\n" |
| if 'Missing end of raw directive' in exception_message: |
| exception_message += \ |
| "In the workload files, \"{% raw -%}\" was provided but is missing it's associated \"{% endraw -%}\" tag.\n" |
| |
| raise exceptions.SystemSetupError(exception_message) |
| |
| |
| except json.JSONDecodeError as e: |
| self.logger.exception("Could not load [%s].", workload_spec_file) |
| msg = "Could not load '{}': {}.".format(workload_spec_file, str(e)) |
| if e.doc and e.lineno > 0 and e.colno > 0: |
| line_idx = e.lineno - 1 |
| lines = e.doc.split("\n") |
| ctx_line_count = 3 |
| ctx_start = max(0, line_idx - ctx_line_count) |
| ctx_end = min(line_idx + ctx_line_count, len(lines)) |
| erroneous_lines = lines[ctx_start:ctx_end] |
| erroneous_lines.insert(line_idx - ctx_start + 1, "-" * (e.colno - 1) + "^ Error is here") |
| msg += " Lines containing the error:\n\n{}\n\n".format("\n".join(erroneous_lines)) |
| msg += "The complete workload has been written to '{}' for diagnosis. \n\n".format(tmp.name) |
| console_message = f"Suggestion: Verify that [{workload_name}] workload has correctly formatted JSON files and " + \ |
| "Jinja Templates. For Jinja2 errors, consider using a live Jinja2 parser. " + \ |
| f"See common workload formatting errors:{WorkloadFileReader.COMMON_WORKLOAD_FORMAT_ERRORS}" |
| msg += console_message |
| raise WorkloadSyntaxError(msg) |
| |
| except Exception as e: |
| # TypeErrors get logged here |
| self.logger.exception("Could not load [%s].", workload_spec_file) |
| msg = "Could not load '{}'. The complete workload has been written to '{}' for diagnosis. \n\n".format( |
| workload_spec_file, tmp.name) |
| console_message = f"Suggestion: Verify that [{workload_name}] workload has correctly formatted JSON files and " + \ |
| "Jinja Templates. For Jinja2 errors, consider using a live Jinja2 parser. " + \ |
| f"See common workload formatting errors:{WorkloadFileReader.COMMON_WORKLOAD_FORMAT_ERRORS}" |
| msg += console_message |
| # Convert to string early on to avoid serialization errors with Jinja exceptions. |
| raise WorkloadSyntaxError(msg, str(e)) |
| # check the workload version before even attempting to validate the JSON format to avoid bogus errors. |
| raw_version = workload_spec.get("version", WorkloadFileReader.MAXIMUM_SUPPORTED_TRACK_VERSION) |
| try: |
| workload_version = int(raw_version) |
| except ValueError: |
| raise exceptions.InvalidSyntax("version identifier for workload %s must be numeric but was [%s]" % ( |
| workload_name, str(raw_version))) |
| if WorkloadFileReader.MINIMUM_SUPPORTED_TRACK_VERSION > workload_version: |
| raise exceptions.BenchmarkError("Workload {} is on version {} but needs to be updated at least to version {} to work with the " |
| "current version of Solr Orbit.".format(workload_name, workload_version, |
| WorkloadFileReader.MINIMUM_SUPPORTED_TRACK_VERSION)) |
| if WorkloadFileReader.MAXIMUM_SUPPORTED_TRACK_VERSION < workload_version: |
| raise exceptions.BenchmarkError("Workload {} requires a newer version of Solr Orbit. " |
| "Please upgrade Solr Orbit (supported workload version: {}, " |
| "required workload version: {}).".format( |
| workload_name, |
| WorkloadFileReader.MAXIMUM_SUPPORTED_TRACK_VERSION, |
| workload_version)) |
| try: |
| jsonschema.validate(workload_spec, self.workload_schema) |
| except jsonschema.exceptions.ValidationError as ve: |
| raise WorkloadSyntaxError( |
| "Workload '{}' is invalid.\n\nError details: {}\nInstance: {}\nPath: {}\nSchema path: {}".format( |
| workload_name, ve.message, json.dumps( |
| ve.instance, indent=4, sort_keys=True), |
| ve.absolute_path, ve.absolute_schema_path)) |
| |
| try: |
| current_workload = self.read_workload(workload_name, workload_spec, mapping_dir) |
| except Exception as e: |
| console.error(e) |
| raise |
| |
| unused_user_defined_workload_params = self.complete_workload_params.unused_user_defined_workload_params() |
| if len(unused_user_defined_workload_params) > 0: |
| err_msg = ( |
| "Some of your workload parameter(s) {} are not used by this workload; perhaps you intend to use {} instead.\n\n" |
| "All workload parameters you provided are:\n" |
| "{}\n\n" |
| "All parameters exposed by this workload:\n" |
| "{}".format( |
| ",".join(opts.double_quoted_list_of(sorted(unused_user_defined_workload_params))), |
| ",".join(opts.double_quoted_list_of(sorted(opts.make_list_of_close_matches( |
| unused_user_defined_workload_params, |
| self.complete_workload_params.workload_defined_params |
| )))), |
| "\n".join(opts.bulleted_list_of(sorted(list(self.workload_params.keys())))), |
| "\n".join(opts.bulleted_list_of(self.complete_workload_params.sorted_workload_defined_params)))) |
| |
| self.logger.critical(err_msg) |
| # also dump the message on the console |
| console.println(err_msg) |
| raise exceptions.WorkloadConfigError( |
| "Unused workload parameters {}.".format(sorted(unused_user_defined_workload_params)) |
| ) |
| return current_workload |
| |
| |
| class WorkloadPluginReader: |
| """ |
| Loads workload plugins |
| """ |
| |
| def __init__(self, workload_plugin_path, runner_registry=None, scheduler_registry=None, workload_processor_registry=None): |
| self.runner_registry = runner_registry |
| self.scheduler_registry = scheduler_registry |
| self.workload_processor_registry = workload_processor_registry |
| self.loader = modules.ComponentLoader(root_path=workload_plugin_path, component_entry_point="workload") |
| |
| def can_load(self): |
| return self.loader.can_load() |
| |
| def load(self): |
| root_module = self.loader.load() |
| try: |
| # every module needs to have a register() method |
| root_module.register(self) |
| except BaseException: |
| msg = "Could not register workload plugin at [%s]" % self.loader.root_path |
| logging.getLogger(__name__).exception(msg) |
| raise exceptions.SystemSetupError(msg) |
| |
| def register_param_source(self, name, param_source): |
| params.register_param_source_for_name(name, param_source) |
| |
| def register_runner(self, name, runner, **kwargs): |
| if self.runner_registry: |
| self.runner_registry(name, runner, **kwargs) |
| |
| def register_scheduler(self, name, scheduler): |
| if self.scheduler_registry: |
| self.scheduler_registry(name, scheduler) |
| |
| def register_workload_processor(self, workload_processor): |
| if self.workload_processor_registry: |
| self.workload_processor_registry(workload_processor) |
| |
| def register_standard_value_source(self, op_name, field_name, standard_value_source): |
| # Define a value source for parameters for a given operation name and field name, for use in randomization |
| params.register_standard_value_source(op_name, field_name, standard_value_source) |
| |
| def register_query_randomization_info(self, op_name, query_name, parameter_name_options_list, optional_parameters): |
| params.register_query_randomization_info(op_name, query_name, parameter_name_options_list, optional_parameters) |
| |
| @property |
| def meta_data(self): |
| return { |
| "benchmark_version": version.release_version(), |
| "async_runner": True |
| } |
| |
| |
| class WorkloadSpecificationReader: |
| """ |
| Creates a workload instances based on its parsed JSON description. |
| """ |
| |
| def __init__(self, workload_params=None, complete_workload_params=None, selected_test_procedure=None, source=io.FileSource): |
| self.name = None |
| self.workload_params = workload_params if workload_params else {} |
| self.complete_workload_params = complete_workload_params |
| self.selected_test_procedure = selected_test_procedure |
| self.source = source |
| self.logger = logging.getLogger(__name__) |
| |
| def __call__(self, workload_name, workload_specification, mapping_dir): |
| self.name = workload_name |
| description = self._r(workload_specification, "description", mandatory=False, default_value="") |
| |
| meta_data = self._r(workload_specification, "meta", mandatory=False) |
| collections = [self._create_collection(col, mapping_dir) |
| for col in self._r(workload_specification, "collections", mandatory=False, default_value=[])] |
| corpora = self._create_corpora(self._r(workload_specification, "corpora", mandatory=False, default_value=[]), |
| collections=collections) |
| test_procedures = self._create_test_procedures(workload_specification) |
| # at this point, *all* workload params must have been referenced in the templates |
| return workload.Workload(name=self.name, meta_data=meta_data, |
| description=description, test_procedures=test_procedures, |
| corpora=corpora, collections=collections) |
| |
| def _error(self, msg): |
| raise WorkloadSyntaxError("Workload '%s' is invalid. %s" % (self.name, msg)) |
| |
| def _r(self, root, path, error_ctx=None, mandatory=True, default_value=None): |
| if isinstance(path, str): |
| path = [path] |
| |
| structure = root |
| try: |
| for k in path: |
| structure = structure[k] |
| return structure |
| except KeyError: |
| if mandatory: |
| if error_ctx: |
| self._error("Mandatory element '%s' is missing in '%s'." % (".".join(path), error_ctx)) |
| else: |
| self._error("Mandatory element '%s' is missing." % ".".join(path)) |
| else: |
| return default_value |
| |
| def _create_collection(self, col_spec, mapping_dir=None): |
| """Create a Solr Collection from a workload spec dict.""" |
| name = self._r(col_spec, "name") |
| configset = self._r(col_spec, "configset", mandatory=False, default_value=name) |
| configset_path = self._r(col_spec, "configset-path", mandatory=False, default_value=None) |
| # Resolve relative configset_path against mapping_dir (the workload directory) |
| if configset_path and mapping_dir and not os.path.isabs(configset_path): |
| configset_path = os.path.join(mapping_dir, configset_path) |
| num_shards = int(self._r(col_spec, "num-shards", mandatory=False, default_value=1)) |
| replication_factor = int(self._r(col_spec, "replication-factor", mandatory=False, default_value=1)) |
| pull_replicas = int(self._r(col_spec, "pull-replicas", mandatory=False, default_value=0)) |
| tlog_replicas = int(self._r(col_spec, "tlog-replicas", mandatory=False, default_value=0)) |
| return workload.Collection( |
| name=name, |
| configset=configset, |
| configset_path=configset_path, |
| num_shards=num_shards, |
| replication_factor=replication_factor, |
| pull_replicas=pull_replicas, |
| tlog_replicas=tlog_replicas, |
| ) |
| |
| def _load_template(self, contents, description): |
| self.logger.info("Loading template [%s].", description) |
| register_all_params_in_workload(contents, self.complete_workload_params) |
| try: |
| rendered = render_template(template_source=contents, |
| template_vars=self.workload_params) |
| return json.loads(rendered) |
| except Exception as e: |
| self.logger.exception("Could not load file template for %s.", description) |
| raise WorkloadSyntaxError("Could not load file template for '%s'" % description, str(e)) |
| |
| def _create_corpora(self, corpora_specs, collections=None): |
| collections = collections or [] |
| document_corpora = [] |
| known_corpora_names = set() |
| for corpus_spec in corpora_specs: |
| name = self._r(corpus_spec, "name") |
| |
| if name in known_corpora_names: |
| self._error("Duplicate document corpus name [%s]." % name) |
| known_corpora_names.add(name) |
| |
| meta_data = self._r(corpus_spec, "meta", error_ctx=name, mandatory=False) |
| streaming_ingestion = self._r(corpus_spec, "streaming-ingestion", mandatory=False, |
| default_value="") |
| corpus = workload.DocumentCorpus(name=name, streaming_ingestion=streaming_ingestion, meta_data=meta_data) |
| # defaults on corpus level |
| default_base_url = self._r(corpus_spec, "base-url", mandatory=False, default_value=None) |
| default_source_format = self._r(corpus_spec, "source-format", mandatory=False, |
| default_value=workload.Documents.SOURCE_FORMAT_BULK) |
| default_action_and_meta_data = self._r(corpus_spec, "includes-action-and-meta-data", mandatory=False, |
| default_value=False) |
| corpus_target_idx = None |
| |
| if len(collections) == 1: |
| corpus_target_idx = self._r(corpus_spec, "target-collection", mandatory=False, default_value=collections[0].name) |
| elif len(collections) > 1: |
| corpus_target_idx = self._r(corpus_spec, "target-collection", mandatory=False) |
| |
| for doc_spec in self._r(corpus_spec, "documents"): |
| base_url = self._r(doc_spec, "base-url", mandatory=False, default_value=default_base_url) |
| source_format = self._r(doc_spec, "source-format", mandatory=False, default_value=default_source_format) |
| |
| if source_format in workload.Documents.SUPPORTED_SOURCE_FORMAT: |
| source_url = self._r(doc_spec, "source-url", mandatory=False) |
| docs = self._r(doc_spec, "source-file") |
| document_file_parts = list() |
| for parts in self._r(doc_spec, "source-file-parts", mandatory=False, default_value=[]): |
| document_file_parts.append({"name": self._r(parts, "name"), "size": self._r(parts, "size")}) |
| if io.is_archive(docs): |
| document_archive = docs |
| document_file = io.splitext(docs)[0] |
| else: |
| document_archive = None |
| document_file = docs |
| num_docs = self._r(doc_spec, "document-count", mandatory=not streaming_ingestion) |
| compressed_bytes = self._r(doc_spec, "compressed-bytes", mandatory=False) |
| uncompressed_bytes = self._r(doc_spec, "uncompressed-bytes", mandatory=False) |
| doc_meta_data = self._r(doc_spec, "meta", error_ctx=name, mandatory=False) |
| |
| includes_action_and_meta_data = self._r(doc_spec, "includes-action-and-meta-data", mandatory=False, |
| default_value=default_action_and_meta_data) |
| if includes_action_and_meta_data: |
| target_idx = None |
| target_type = None |
| else: |
| target_type = None |
| target_idx = self._r(doc_spec, "target-collection", |
| mandatory=len(collections) > 0 and corpus_target_idx is None, |
| default_value=corpus_target_idx, |
| error_ctx=docs) |
| |
| docs = workload.Documents(source_format=source_format, |
| document_file=document_file, |
| document_file_parts=document_file_parts, |
| document_archive=document_archive, |
| base_url=base_url, |
| source_url=source_url, |
| includes_action_and_meta_data=includes_action_and_meta_data, |
| number_of_documents=num_docs, |
| compressed_size_in_bytes=compressed_bytes, |
| uncompressed_size_in_bytes=uncompressed_bytes, |
| target_collection=target_idx, target_type=target_type, |
| meta_data=doc_meta_data) |
| corpus.documents.append(docs) |
| else: |
| self._error("Unknown source-format [%s] in document corpus [%s]." % (source_format, name)) |
| document_corpora.append(corpus) |
| return document_corpora |
| |
| def _create_test_procedures(self, workload_spec): |
| ops = self.parse_operations(self._r(workload_spec, "operations", mandatory=False, default_value=[])) |
| workload_params = self._r(workload_spec, "parameters", mandatory=False, default_value={}) |
| test_procedures = [] |
| known_test_procedure_names = set() |
| default_test_procedure = None |
| test_procedure_specs, auto_generated = self._get_test_procedure_specs(workload_spec) |
| number_of_test_procedures = len(test_procedure_specs) |
| for test_procedure_spec in test_procedure_specs: |
| name = self._r(test_procedure_spec, "name", error_ctx="test_procedures") |
| description = self._r(test_procedure_spec, "description", error_ctx=name, mandatory=False) |
| user_info = self._r(test_procedure_spec, "user-info", error_ctx=name, mandatory=False) |
| test_procedure_params = self._r(test_procedure_spec, "parameters", error_ctx=name, mandatory=False, default_value={}) |
| meta_data = self._r(test_procedure_spec, "meta", error_ctx=name, mandatory=False) |
| # if we only have one test_procedure it is treated as default test_procedure, no matter what the user has specified |
| default = number_of_test_procedures == 1 or self._r(test_procedure_spec, "default", error_ctx=name, mandatory=False) |
| selected = number_of_test_procedures == 1 or self.selected_test_procedure == name |
| if default and default_test_procedure is not None: |
| self._error("Both '%s' and '%s' are defined as default test_procedures. Please define only one of them as default." |
| % (default_test_procedure.name, name)) |
| if name in known_test_procedure_names: |
| self._error("Duplicate test_procedure with name '%s'." % name) |
| known_test_procedure_names.add(name) |
| |
| schedule = [] |
| |
| for op in self._r(test_procedure_spec, "schedule", error_ctx=name): |
| if "clients_list" in op: |
| self.logger.info("Clients list specified: %s. Running multiple search tasks, "\ |
| "each scheduled with the corresponding number of clients from the list.", op["clients_list"]) |
| for num_clients in op["clients_list"]: |
| op["clients"] = num_clients |
| |
| new_name = self._rename_task_based_on_num_clients(name, num_clients) |
| |
| new_name = name + "_" + str(num_clients) + "_clients" |
| new_task = self.parse_task(op, ops, new_name) |
| new_task.name = new_name |
| schedule.append(new_task) |
| else: |
| if "parallel" in op: |
| task = self.parse_parallel(op["parallel"], ops, name) |
| else: |
| task = self.parse_task(op, ops, name) |
| |
| schedule.append(task) |
| |
| # verify we don't have any duplicate task names (which can be confusing / misleading in reporting). |
| known_task_names = set() |
| for task in schedule: |
| for sub_task in task: |
| if sub_task.name in known_task_names: |
| self._error("TestProcedure '%s' contains multiple tasks with the name '%s'. Please use the task's name property to " |
| "assign a unique name for each task." % (name, sub_task.name)) |
| else: |
| known_task_names.add(sub_task.name) |
| |
| # merge params |
| final_test_procedure_params = dict(collections.merge_dicts(workload_params, test_procedure_params)) |
| |
| test_procedure = workload.TestProcedure(name=name, |
| parameters=final_test_procedure_params, |
| meta_data=meta_data, |
| description=description, |
| user_info=user_info, |
| default=default, |
| selected=selected, |
| auto_generated=auto_generated, |
| schedule=schedule) |
| if default: |
| default_test_procedure = test_procedure |
| |
| test_procedures.append(test_procedure) |
| |
| if test_procedures and default_test_procedure is None: |
| self._error( |
| "No default test_procedure specified. Please edit the workload and add \"default\": true to one of the test_procedures %s." |
| % ", ".join([c.name for c in test_procedures])) |
| return test_procedures |
| |
| def _rename_task_based_on_num_clients(self, name: str, num_clients: int) -> str: |
| has_underscore = "_" in name |
| has_hyphen = "-" in name |
| if has_underscore and has_hyphen: |
| self.logger.warning("The test procedure name %s contains a mix of _ and -. "\ |
| "Consider changing the name to avoid frustrating bugs in the future.", name) |
| return name + "_" + str(num_clients) + "_clients" |
| elif has_hyphen: |
| return name + "-" + str(num_clients) + "-clients" |
| else: |
| return name + "_" + str(num_clients) + "_clients" |
| |
| def _get_test_procedure_specs(self, workload_spec): |
| schedule = self._r(workload_spec, "schedule", mandatory=False) |
| test_procedure = self._r(workload_spec, "test_procedure", mandatory=False) |
| test_procedures = self._r(workload_spec, "test_procedures", mandatory=False) |
| |
| count_defined = len(list(filter(lambda e: e is not None, [schedule, test_procedure, test_procedures]))) |
| |
| if count_defined == 0: |
| self._error("You must define 'test_procedure', 'test_procedures' or 'schedule' but none is specified.") |
| elif count_defined > 1: |
| self._error("Multiple out of 'test_procedure', 'test_procedures' or 'schedule' are defined but only one of them is allowed.") |
| elif test_procedure is not None: |
| return [test_procedure], False |
| elif test_procedures is not None: |
| return test_procedures, False |
| elif schedule is not None: |
| return [{ |
| "name": "default", |
| "schedule": schedule |
| }], True |
| else: |
| raise AssertionError( |
| "Unexpected: schedule=[{}], test_procedure=[{}], test_procedures=[{}]".format( |
| schedule, test_procedure, test_procedures)) |
| |
| def parse_parallel(self, ops_spec, ops, test_procedure_name): |
| # use same default values as #parseTask() in case the 'parallel' element did not specify anything |
| default_warmup_iterations = self._r(ops_spec, "warmup-iterations", error_ctx="parallel", mandatory=False) |
| default_iterations = self._r(ops_spec, "iterations", error_ctx="parallel", mandatory=False) |
| default_warmup_time_period = self._r(ops_spec, "warmup-time-period", error_ctx="parallel", mandatory=False) |
| default_time_period = self._r(ops_spec, "time-period", error_ctx="parallel", mandatory=False) |
| default_ramp_up_time_period = self._r(ops_spec, "ramp-up-time-period", error_ctx="parallel", mandatory=False) |
| default_ramp_down_time_period = self._r(ops_spec, "ramp-down-time-period", error_ctx="parallel", mandatory=False) |
| clients = self._r(ops_spec, "clients", error_ctx="parallel", mandatory=False) |
| completed_by = self._r(ops_spec, "completed-by", error_ctx="parallel", mandatory=False) |
| |
| # now descent to each operation |
| tasks = [] |
| for task in self._r(ops_spec, "tasks", error_ctx="parallel"): |
| tasks.append(self.parse_task(task, ops, test_procedure_name, default_warmup_iterations, default_iterations, |
| default_warmup_time_period, default_time_period, default_ramp_up_time_period, |
| default_ramp_down_time_period, completed_by)) |
| |
| for task in tasks: |
| if task.ramp_up_time_period != default_ramp_up_time_period: |
| if default_ramp_up_time_period is None: |
| self._error(f"task '{task.name}' in 'parallel' element of test-procedure '{test_procedure_name}' specifies " |
| f"a ramp-up-time-period but it is only allowed on the 'parallel' element.") |
| else: |
| self._error(f"task '{task.name}' specifies a different ramp-up-time-period than its enclosing " |
| f"'parallel' element in test-procedure '{test_procedure_name}'.") |
| if task.ramp_down_time_period != default_ramp_down_time_period: |
| if default_ramp_down_time_period is None: |
| self._error(f"task '{task.name}' in 'parallel' element of test-procedure '{test_procedure_name}' specifies " |
| f"a ramp-down-time-period but it is only allowed on the 'parallel' element.") |
| else: |
| self._error(f"task '{task.name}' specifies a different ramp-down-time-period than its enclosing " |
| f"'parallel' element in test-procedure '{test_procedure_name}'.") |
| if completed_by: |
| completion_task = None |
| for task in tasks: |
| if task.completes_parent and not completion_task: |
| completion_task = task |
| elif task.completes_parent: |
| self._error( |
| "'parallel' element for test_procedure '%s' contains multiple tasks with the name '%s' which are marked with " |
| "'completed-by' but only task is allowed to match." % (test_procedure_name, completed_by)) |
| if not completion_task: |
| self._error("'parallel' element for test_procedure '%s' is marked with 'completed-by' with task name '%s' but no task with " |
| "this name exists." % (test_procedure_name, completed_by)) |
| return workload.Parallel(tasks, clients) |
| |
| def parse_task(self, task_spec, ops, test_procedure_name, default_warmup_iterations=None, default_iterations=None, |
| default_warmup_time_period=None, default_time_period=None, default_ramp_up_time_period=None, default_ramp_down_time_period=None, |
| completed_by_name=None): |
| |
| op_spec = task_spec["operation"] |
| if isinstance(op_spec, str) and op_spec in ops: |
| op = ops[op_spec] |
| else: |
| # may as well an inline operation |
| op = self.parse_operation(op_spec, error_ctx="inline operation in test_procedure %s" % test_procedure_name) |
| |
| schedule = self._r(task_spec, "schedule", error_ctx=op.name, mandatory=False) |
| task_name = self._r(task_spec, "name", error_ctx=op.name, mandatory=False, default_value=op.name) |
| task = workload.Task(name=task_name, |
| operation=op, |
| tags=self._r(task_spec, "tags", error_ctx=op.name, mandatory=False), |
| meta_data=self._r(task_spec, "meta", error_ctx=op.name, mandatory=False), |
| warmup_iterations=self._r(task_spec, "warmup-iterations", error_ctx=op.name, mandatory=False, |
| default_value=default_warmup_iterations), |
| iterations=self._r(task_spec, "iterations", error_ctx=op.name, mandatory=False, default_value=default_iterations), |
| warmup_time_period=self._r(task_spec, "warmup-time-period", error_ctx=op.name, |
| mandatory=False, |
| default_value=default_warmup_time_period), |
| time_period=self._r(task_spec, "time-period", error_ctx=op.name, mandatory=False, |
| default_value=default_time_period), |
| ramp_up_time_period=self._r(task_spec, "ramp-up-time-period", error_ctx=op.name, |
| mandatory=False, default_value=default_ramp_up_time_period), |
| ramp_down_time_period=self._r(task_spec, "ramp-down-time-period", error_ctx=op.name, |
| mandatory=False, default_value=default_ramp_down_time_period), |
| clients=self._r(task_spec, "clients", error_ctx=op.name, mandatory=False, default_value=1), |
| completes_parent=(task_name == completed_by_name), |
| schedule=schedule, |
| # this is to provide scheduler-specific parameters for custom schedulers. |
| params=task_spec) |
| if task.warmup_iterations is not None and task.time_period is not None: |
| self._error( |
| "Operation '%s' in test_procedure '%s' defines '%d' warmup iterations and a time period of '%d' seconds. Please do not " |
| "mix time periods and iterations." % (op.name, test_procedure_name, task.warmup_iterations, task.time_period)) |
| elif task.warmup_time_period is not None and task.iterations is not None: |
| self._error( |
| "Operation '%s' in test_procedure '%s' defines a warmup time period of '%d' seconds and '%d' iterations. Please do not " |
| "mix time periods and iterations." % (op.name, test_procedure_name, task.warmup_time_period, task.iterations)) |
| |
| if (task.warmup_iterations is not None or task.iterations is not None) and task.ramp_up_time_period is not None: |
| self._error(f"Operation '{op.name}' in test_procedure '{test_procedure_name}' defines a ramp-up time period of " |
| f"{task.ramp_up_time_period} seconds as well as {task.warmup_iterations} warmup iterations and " |
| f"{task.iterations} iterations but mixing time periods and iterations is not allowed.") |
| |
| if task.ramp_up_time_period is not None: |
| if task.warmup_time_period is None: |
| self._error(f"Operation '{op.name}' in test_procedure '{test_procedure_name}' defines a ramp-up time period of " |
| f"{task.ramp_up_time_period} seconds but no warmup-time-period.") |
| elif task.warmup_time_period < task.ramp_up_time_period: |
| self._error(f"The warmup-time-period of operation '{op.name}' in test_procedure '{test_procedure_name}' is " |
| f"{task.warmup_time_period} seconds but must be greater than or equal to the " |
| f"ramp-up-time-period of {task.ramp_up_time_period} seconds.") |
| if task.ramp_down_time_period is not None: |
| if task.time_period is None: |
| self._error(f"Operation '{op.name}' in test_procedure '{test_procedure_name}' defines a ramp-down time period of " |
| f"{task.ramp_down_time_period} seconds but no time-period.") |
| elif task.time_period < task.ramp_down_time_period: |
| self._error(f"The time-period of operation '{op.name}' in test_procedure '{test_procedure_name}' is " |
| f"{task.time_period} seconds but must be greater than or equal to the " |
| f"ramp-down-time-period of {task.ramp_down_time_period} seconds.") |
| |
| if (task.warmup_iterations is not None or task.iterations is not None) and task.ramp_down_time_period is not None: |
| self._error(f"Operation '{op.name}' in test_procedure '{test_procedure_name}' defines a ramp-down time period of " |
| f"{task.ramp_down_time_period} seconds as well as {task.warmup_iterations} warmup iterations and " |
| f"{task.iterations} iterations but mixing time periods and iterations is not allowed.") |
| |
| return task |
| |
| def parse_operations(self, ops_specs): |
| # key = name, value = operation |
| ops = {} |
| for op_spec in ops_specs: |
| op = self.parse_operation(op_spec) |
| if op.name in ops: |
| self._error("Duplicate operation with name '%s'." % op.name) |
| else: |
| ops[op.name] = op |
| return ops |
| |
| def parse_operation(self, op_spec, error_ctx="operations"): |
| # just a name, let's assume it is a simple operation like force-merge and create a full operation |
| if isinstance(op_spec, str): |
| op_name = op_spec |
| meta_data = None |
| op_type_name = op_spec |
| param_source = None |
| # Cannot have parameters here |
| params = {} |
| else: |
| meta_data = self._r(op_spec, "meta", error_ctx=error_ctx, mandatory=False) |
| # OSB's core operations will still use enums then but we'll allow users to define arbitrary operations |
| op_type_name = self._r(op_spec, "operation-type", error_ctx=error_ctx) |
| # fallback to use the operation type as the operation name |
| op_name = self._r(op_spec, "name", error_ctx=error_ctx, mandatory=False, default_value=op_type_name) |
| param_source = self._r(op_spec, "param-source", error_ctx=error_ctx, mandatory=False) |
| # just pass-through all parameters by default |
| params = op_spec |
| |
| try: |
| op = workload.OperationType.from_hyphenated_string(op_type_name) |
| if "include-in-reporting" not in params: |
| params["include-in-reporting"] = not op.admin_op |
| self.logger.debug("Using built-in operation type [%s] for operation [%s].", op_type_name, op_name) |
| except KeyError: |
| self.logger.info("Using user-provided operation type [%s] for operation [%s].", op_type_name, op_name) |
| |
| try: |
| return workload.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 WorkloadSyntaxError("Invalid operation [%s]: %s" % (op_name, str(e))) |