| #!/usr/bin/env python3 |
| |
| # Copyright 2023 gRPC authors. |
| # |
| # Licensed 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. |
| """ |
| A module to assist in generating experiment related code and artifacts. |
| """ |
| |
| from __future__ import print_function |
| |
| import collections |
| from copy import deepcopy |
| import ctypes |
| import datetime |
| import json |
| import math |
| import os |
| import re |
| import sys |
| |
| import yaml |
| |
| _CODEGEN_PLACEHOLDER_TEXT = """ |
| This file contains the autogenerated parts of the experiments API. |
| |
| It generates two symbols for each experiment. |
| |
| For the experiment named new_car_project, it generates: |
| |
| - a function IsNewCarProjectEnabled() that returns true if the experiment |
| should be enabled at runtime. |
| |
| - a macro GRPC_EXPERIMENT_IS_INCLUDED_NEW_CAR_PROJECT that is defined if the |
| experiment *could* be enabled at runtime. |
| |
| The function is used to determine whether to run the experiment or |
| non-experiment code path. |
| |
| If the experiment brings significant bloat, the macro can be used to avoid |
| including the experiment code path in the binary for binaries that are size |
| sensitive. |
| |
| By default that includes our iOS and Android builds. |
| |
| Finally, a small array is included that contains the metadata for each |
| experiment. |
| |
| A macro, GRPC_EXPERIMENTS_ARE_FINAL, controls whether we fix experiment |
| configuration at build time (if it's defined) or allow it to be tuned at |
| runtime (if it's disabled). |
| |
| If you are using the Bazel build system, that macro can be configured with |
| --define=grpc_experiments_are_final=true |
| """ |
| |
| |
| def _EXPERIMENTS_TEST_SKELETON(defs, test_body): |
| return f""" |
| #include <grpc/support/port_platform.h> |
| |
| #include "test/core/experiments/fixtures/experiments.h" |
| |
| #include <memory> |
| |
| #include "gtest/gtest.h" |
| |
| #include "src/core/lib/experiments/config.h" |
| |
| #ifndef GRPC_EXPERIMENTS_ARE_FINAL |
| {defs} |
| TEST(ExperimentsTest, CheckExperimentValuesTest) {{ |
| {test_body} |
| }} |
| |
| #endif // GRPC_EXPERIMENTS_ARE_FINAL |
| |
| int main(int argc, char** argv) {{ |
| testing::InitGoogleTest(&argc, argv); |
| grpc_core::LoadTestOnlyExperimentsFromMetadata( |
| grpc_core::g_test_experiment_metadata, grpc_core::kNumTestExperiments); |
| return RUN_ALL_TESTS(); |
| }} |
| """ |
| |
| |
| def _EXPERIMENTS_EXPECTED_VALUE(name, expected_value): |
| return f""" |
| bool GetExperiment{name}ExpectedValue() {{ |
| {expected_value} |
| }} |
| """ |
| |
| |
| def _EXPERIMENT_CHECK_TEXT(name): |
| return f""" |
| ASSERT_EQ(grpc_core::Is{name}Enabled(), |
| GetExperiment{name}ExpectedValue()); |
| """ |
| |
| |
| def ToCStr(s, encoding="ascii"): |
| if isinstance(s, str): |
| s = s.encode(encoding) |
| result = "" |
| for c in s: |
| c = chr(c) if isinstance(c, int) else c |
| if not (32 <= ord(c) < 127) or c in ("\\", '"'): |
| result += "\\%03o" % ord(c) |
| else: |
| result += c |
| return '"' + result + '"' |
| |
| |
| def SnakeToPascal(s): |
| return "".join(x.capitalize() for x in s.split("_")) |
| |
| |
| def PutBanner(files, banner, prefix): |
| # Print a big comment block into a set of files |
| for f in files: |
| for line in banner: |
| if not line: |
| print(prefix, file=f) |
| else: |
| print("%s %s" % (prefix, line), file=f) |
| print(file=f) |
| |
| |
| def PutCopyright(file, prefix): |
| # copy-paste copyright notice from this file |
| with open(__file__) as my_source: |
| copyright = [] |
| for line in my_source: |
| if line[0] != "#": |
| break |
| for line in my_source: |
| if line[0] == "#": |
| copyright.append(line) |
| break |
| for line in my_source: |
| if line[0] != "#": |
| break |
| copyright.append(line) |
| PutBanner([file], [line[2:].rstrip() for line in copyright], prefix) |
| |
| |
| def AreExperimentsOrdered(experiments): |
| # Check that the experiments are ordered by name |
| for i in range(1, len(experiments)): |
| if experiments[i - 1]["name"] >= experiments[i]["name"]: |
| print( |
| "Experiments are unordered: %s should be after %s" |
| % (experiments[i - 1]["name"], experiments[i]["name"]) |
| ) |
| return False |
| return True |
| |
| |
| class ExperimentDefinition(object): |
| def __init__(self, attributes): |
| self._error = False |
| if "name" not in attributes: |
| print("ERROR: experiment with no name: %r" % attributes) |
| self._error = True |
| if "description" not in attributes: |
| print( |
| "ERROR: no description for experiment %s" % attributes["name"] |
| ) |
| self._error = True |
| if "owner" not in attributes: |
| print("ERROR: no owner for experiment %s" % attributes["name"]) |
| self._error = True |
| if "expiry" not in attributes: |
| print("ERROR: no expiry for experiment %s" % attributes["name"]) |
| self._error = True |
| if attributes["name"] == "monitoring_experiment": |
| if attributes["expiry"] != "never-ever": |
| print("ERROR: monitoring_experiment should never expire") |
| self._error = True |
| if self._error: |
| print("Failed to create experiment definition") |
| return |
| self._allow_in_fuzzing_config = True |
| self._uses_polling = False |
| self._name = attributes["name"] |
| self._description = attributes["description"] |
| self._expiry = attributes["expiry"] |
| self._default = {} |
| self._additional_constraints = {} |
| self._test_tags = [] |
| self._requires = set() |
| |
| if "uses_polling" in attributes: |
| self._uses_polling = attributes["uses_polling"] |
| |
| if "allow_in_fuzzing_config" in attributes: |
| self._allow_in_fuzzing_config = attributes[ |
| "allow_in_fuzzing_config" |
| ] |
| |
| if "test_tags" in attributes: |
| self._test_tags = attributes["test_tags"] |
| |
| for requirement in attributes.get("requires", []): |
| self._requires.add(requirement) |
| |
| def IsValid(self, check_expiry=False): |
| if self._error: |
| return False |
| if not check_expiry: |
| return True |
| if ( |
| self._name == "monitoring_experiment" |
| and self._expiry == "never-ever" |
| ): |
| return True |
| today = datetime.date.today() |
| two_quarters_from_now = today + datetime.timedelta(days=180) |
| expiry = datetime.datetime.strptime(self._expiry, "%Y/%m/%d").date() |
| if expiry < today: |
| print( |
| "WARNING: experiment %s expired on %s" |
| % (self._name, self._expiry) |
| ) |
| if expiry > two_quarters_from_now: |
| print( |
| "WARNING: experiment %s expires far in the future on %s" |
| % (self._name, self._expiry) |
| ) |
| print("expiry should be no more than two quarters from now") |
| return not self._error |
| |
| def AddRolloutSpecification( |
| self, allowed_defaults, allowed_platforms, rollout_attributes |
| ): |
| if self._error: |
| return False |
| if rollout_attributes["name"] != self._name: |
| print( |
| "ERROR: Rollout specification does not apply to this" |
| " experiment: %s" % self._name |
| ) |
| return False |
| for requirement in rollout_attributes.get("requires", []): |
| self._requires.add(requirement) |
| if "default" not in rollout_attributes: |
| print( |
| "ERROR: no default for experiment %s" |
| % rollout_attributes["name"] |
| ) |
| self._error = True |
| return False |
| is_dict = isinstance(rollout_attributes["default"], dict) |
| for platform in allowed_platforms: |
| if is_dict: |
| value = rollout_attributes["default"].get(platform, False) |
| else: |
| value = rollout_attributes["default"] |
| if isinstance(value, dict): |
| self._default[platform] = "debug" |
| self._additional_constraints[platform] = value |
| elif value not in allowed_defaults: |
| print( |
| "ERROR: default for experiment %s on platform %s " |
| "is of incorrect format" |
| % (rollout_attributes["name"], platform) |
| ) |
| self._error = True |
| return False |
| else: |
| self._default[platform] = value |
| self._additional_constraints[platform] = {} |
| return True |
| |
| @property |
| def name(self): |
| return self._name |
| |
| @property |
| def description(self): |
| return self._description |
| |
| def default(self, platform): |
| return self._default.get(platform, False) |
| |
| @property |
| def test_tags(self): |
| return self._test_tags |
| |
| @property |
| def allow_in_fuzzing_config(self): |
| return self._allow_in_fuzzing_config |
| |
| def additional_constraints(self, platform): |
| return self._additional_constraints.get(platform, {}) |
| |
| |
| class ExperimentsCompiler(object): |
| def __init__( |
| self, |
| defaults, |
| final_return, |
| final_define, |
| platforms_define, |
| bzl_list_for_defaults=None, |
| ): |
| self._defaults = defaults |
| self._final_return = final_return |
| self._final_define = final_define |
| self._platforms_define = platforms_define |
| self._bzl_list_for_defaults = bzl_list_for_defaults |
| self._experiment_definitions = collections.OrderedDict() |
| self._experiment_rollouts = {} |
| |
| def AddExperimentDefinition(self, experiment_definition): |
| if experiment_definition.name in self._experiment_definitions: |
| print( |
| "ERROR: Duplicate experiment definition: %s" |
| % experiment_definition.name |
| ) |
| return False |
| self._experiment_definitions[ |
| experiment_definition.name |
| ] = experiment_definition |
| return True |
| |
| def AddRolloutSpecification(self, rollout_attributes): |
| if "name" not in rollout_attributes: |
| print( |
| "ERROR: experiment with no name: %r in rollout_attribute" |
| % rollout_attributes |
| ) |
| return False |
| if rollout_attributes["name"] not in self._experiment_definitions: |
| print( |
| "WARNING: rollout for an undefined experiment: %s ignored" |
| % rollout_attributes["name"] |
| ) |
| return True |
| return self._experiment_definitions[ |
| rollout_attributes["name"] |
| ].AddRolloutSpecification( |
| self._defaults, self._platforms_define, rollout_attributes |
| ) |
| |
| def _FinalizeExperiments(self): |
| queue = collections.OrderedDict() |
| for name, exp in self._experiment_definitions.items(): |
| queue[name] = exp._requires |
| done = set() |
| final = collections.OrderedDict() |
| while queue: |
| take = None |
| for name, requires in queue.items(): |
| if requires.issubset(done): |
| take = name |
| break |
| if take is None: |
| print("ERROR: circular dependency in experiments") |
| return False |
| done.add(take) |
| final[take] = self._experiment_definitions[take] |
| del queue[take] |
| self._experiment_definitions = final |
| return True |
| |
| def _GenerateExperimentsHdrForPlatform(self, platform, file_desc): |
| for _, exp in self._experiment_definitions.items(): |
| define_fmt = self._final_define[exp.default(platform)] |
| if define_fmt: |
| print( |
| define_fmt |
| % ("GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper()), |
| file=file_desc, |
| ) |
| print( |
| "inline bool Is%sEnabled() { %s }" |
| % ( |
| SnakeToPascal(exp.name), |
| self._final_return[exp.default(platform)], |
| ), |
| file=file_desc, |
| ) |
| |
| def GenerateExperimentsHdr(self, output_file, mode): |
| assert self._FinalizeExperiments() |
| with open(output_file, "w") as H: |
| PutCopyright(H, "//") |
| PutBanner( |
| [H], |
| ["Auto generated by tools/codegen/core/gen_experiments.py"] |
| + _CODEGEN_PLACEHOLDER_TEXT.splitlines(), |
| "//", |
| ) |
| |
| if mode != "test": |
| include_guard = "GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H" |
| else: |
| real_output_file = output_file.replace(".github", "") |
| file_path_list = real_output_file.split("/")[0:-1] |
| file_name = real_output_file.split("/")[-1].split(".")[0] |
| |
| include_guard = f"GRPC_{'_'.join(path.upper() for path in file_path_list)}_{file_name.upper()}_H" |
| |
| print(f"#ifndef {include_guard}", file=H) |
| print(f"#define {include_guard}", file=H) |
| print(file=H) |
| print("#include <grpc/support/port_platform.h>", file=H) |
| print(file=H) |
| print('#include "src/core/lib/experiments/config.h"', file=H) |
| print(file=H) |
| print("namespace grpc_core {", file=H) |
| print(file=H) |
| print("#ifdef GRPC_EXPERIMENTS_ARE_FINAL", file=H) |
| idx = 0 |
| for platform in sorted(self._platforms_define.keys()): |
| if platform == "posix": |
| continue |
| print( |
| f"\n#{'if' if idx ==0 else 'elif'} " |
| f"defined({self._platforms_define[platform]})", |
| file=H, |
| ) |
| self._GenerateExperimentsHdrForPlatform(platform, H) |
| idx += 1 |
| print("\n#else", file=H) |
| self._GenerateExperimentsHdrForPlatform("posix", H) |
| print("#endif", file=H) |
| print("\n#else", file=H) |
| if mode == "test": |
| num_experiments_var_name = "kNumTestExperiments" |
| experiments_metadata_var_name = "g_test_experiment_metadata" |
| else: |
| num_experiments_var_name = "kNumExperiments" |
| experiments_metadata_var_name = "g_experiment_metadata" |
| print("enum ExperimentIds {", file=H) |
| for exp in self._experiment_definitions.values(): |
| print(f" kExperimentId{SnakeToPascal(exp.name)},", file=H) |
| print(f" {num_experiments_var_name}", file=H) |
| print("};", file=H) |
| for exp in self._experiment_definitions.values(): |
| print( |
| "#define GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper(), |
| file=H, |
| ) |
| print( |
| "inline bool Is%sEnabled() { return" |
| " Is%sExperimentEnabled(kExperimentId%s); }" |
| % ( |
| SnakeToPascal(exp.name), |
| "Test" if mode == "test" else "", |
| SnakeToPascal(exp.name), |
| ), |
| file=H, |
| ) |
| print(file=H) |
| print( |
| ( |
| "extern const ExperimentMetadata" |
| f" {experiments_metadata_var_name}[{num_experiments_var_name}];" |
| ), |
| file=H, |
| ) |
| print(file=H) |
| print("#endif", file=H) |
| print("} // namespace grpc_core", file=H) |
| print(file=H) |
| print(f"#endif // {include_guard}", file=H) |
| |
| def _GenerateExperimentsSrcForPlatform(self, platform, mode, file_desc): |
| print("namespace {", file=file_desc) |
| have_defaults = set() |
| for _, exp in self._experiment_definitions.items(): |
| print( |
| "const char* const description_%s = %s;" |
| % (exp.name, ToCStr(exp.description)), |
| file=file_desc, |
| ) |
| print( |
| "const char* const additional_constraints_%s = %s;" |
| % ( |
| exp.name, |
| ToCStr(json.dumps(exp.additional_constraints(platform))), |
| ), |
| file=file_desc, |
| ) |
| have_defaults.add(self._defaults[exp.default(platform)]) |
| if exp._requires: |
| print( |
| "const uint8_t required_experiments_%s[] = {%s};" |
| % ( |
| exp.name, |
| ",".join( |
| f"static_cast<uint8_t>(grpc_core::kExperimentId{SnakeToPascal(name)})" |
| for name in sorted(exp._requires) |
| ), |
| ), |
| file=file_desc, |
| ) |
| if "kDefaultForDebugOnly" in have_defaults: |
| print("#ifdef NDEBUG", file=file_desc) |
| if "kDefaultForDebugOnly" in have_defaults: |
| print( |
| "const bool kDefaultForDebugOnly = false;", file=file_desc |
| ) |
| print("#else", file=file_desc) |
| if "kDefaultForDebugOnly" in have_defaults: |
| print("const bool kDefaultForDebugOnly = true;", file=file_desc) |
| print("#endif", file=file_desc) |
| print("}", file=file_desc) |
| print(file=file_desc) |
| print("namespace grpc_core {", file=file_desc) |
| print(file=file_desc) |
| if mode == "test": |
| experiments_metadata_var_name = "g_test_experiment_metadata" |
| else: |
| experiments_metadata_var_name = "g_experiment_metadata" |
| print( |
| f"const ExperimentMetadata {experiments_metadata_var_name}[] = {{", |
| file=file_desc, |
| ) |
| for _, exp in self._experiment_definitions.items(): |
| print( |
| " {%s, description_%s, additional_constraints_%s, %s, %d, %s, %s}," |
| % ( |
| ToCStr(exp.name), |
| exp.name, |
| exp.name, |
| f"required_experiments_{exp.name}" |
| if exp._requires |
| else "nullptr", |
| len(exp._requires), |
| self._defaults[exp.default(platform)], |
| "true" if exp.allow_in_fuzzing_config else "false", |
| ), |
| file=file_desc, |
| ) |
| print("};", file=file_desc) |
| print(file=file_desc) |
| print("} // namespace grpc_core", file=file_desc) |
| |
| def GenerateExperimentsSrc(self, output_file, header_file_path, mode): |
| assert self._FinalizeExperiments() |
| with open(output_file, "w") as C: |
| PutCopyright(C, "//") |
| PutBanner( |
| [C], |
| ["Auto generated by tools/codegen/core/gen_experiments.py"], |
| "//", |
| ) |
| |
| any_requires = False |
| for _, exp in self._experiment_definitions.items(): |
| if exp._requires: |
| any_requires = True |
| break |
| |
| print("#include <grpc/support/port_platform.h>", file=C) |
| print(file=C) |
| if any_requires: |
| print("#include <stdint.h>", file=C) |
| print(file=C) |
| print( |
| f'#include "{header_file_path.replace(".github", "")}"', file=C |
| ) |
| print(file=C) |
| print("#ifndef GRPC_EXPERIMENTS_ARE_FINAL", file=C) |
| idx = 0 |
| for platform in sorted(self._platforms_define.keys()): |
| if platform == "posix": |
| continue |
| print( |
| f"\n#{'if' if idx ==0 else 'elif'} " |
| f"defined({self._platforms_define[platform]})", |
| file=C, |
| ) |
| self._GenerateExperimentsSrcForPlatform(platform, mode, C) |
| idx += 1 |
| print("\n#else", file=C) |
| self._GenerateExperimentsSrcForPlatform("posix", mode, C) |
| print("#endif", file=C) |
| print("#endif", file=C) |
| |
| def _GenTestExperimentsExpectedValues(self, platform): |
| defs = "" |
| for _, exp in self._experiment_definitions.items(): |
| defs += _EXPERIMENTS_EXPECTED_VALUE( |
| SnakeToPascal(exp.name), |
| self._final_return[exp.default(platform)], |
| ) |
| return defs |
| |
| def GenTest(self, output_file): |
| assert self._FinalizeExperiments() |
| with open(output_file, "w") as C: |
| PutCopyright(C, "//") |
| PutBanner( |
| [C], |
| ["Auto generated by tools/codegen/core/gen_experiments.py"], |
| "//", |
| ) |
| defs = "" |
| test_body = "" |
| idx = 0 |
| for platform in sorted(self._platforms_define.keys()): |
| if platform == "posix": |
| continue |
| defs += ( |
| f"\n#{'if' if idx ==0 else 'elif'} " |
| f"defined({self._platforms_define[platform]})" |
| ) |
| defs += self._GenTestExperimentsExpectedValues(platform) |
| idx += 1 |
| defs += "\n#else" |
| defs += self._GenTestExperimentsExpectedValues("posix") |
| defs += "#endif\n" |
| for _, exp in self._experiment_definitions.items(): |
| test_body += _EXPERIMENT_CHECK_TEXT(SnakeToPascal(exp.name)) |
| print(_EXPERIMENTS_TEST_SKELETON(defs, test_body), file=C) |
| |
| def _ExperimentEnableSet(self, name): |
| s = set() |
| s.add(name) |
| for exp in self._experiment_definitions[name]._requires: |
| for req in self._ExperimentEnableSet(exp): |
| s.add(req) |
| return s |
| |
| def GenExperimentsBzl(self, mode, output_file): |
| assert self._FinalizeExperiments() |
| if self._bzl_list_for_defaults is None: |
| return |
| |
| defaults = dict( |
| (key, collections.defaultdict(list)) |
| for key in self._bzl_list_for_defaults.keys() |
| if key is not None |
| ) |
| |
| bzl_to_tags_to_experiments = dict( |
| (platform, deepcopy(defaults)) |
| for platform in self._platforms_define.keys() |
| ) |
| |
| for platform in self._platforms_define.keys(): |
| for _, exp in self._experiment_definitions.items(): |
| for tag in exp.test_tags: |
| # Search through default values for all platforms. |
| default = exp.default(platform) |
| # Interpret the debug default value as True to switch the |
| # experiment to the "on" mode. |
| if default == "debug": |
| default = True |
| bzl_to_tags_to_experiments[platform][default][tag].append( |
| exp.name |
| ) |
| |
| with open(output_file, "w") as B: |
| PutCopyright(B, "#") |
| PutBanner( |
| [B], |
| ["Auto generated by tools/codegen/core/gen_experiments.py"], |
| "#", |
| ) |
| |
| print( |
| ( |
| '"""Dictionary of tags to experiments so we know when to' |
| ' test different experiments."""' |
| ), |
| file=B, |
| ) |
| |
| print(file=B) |
| if mode == "test": |
| print("TEST_EXPERIMENT_ENABLES = {", file=B) |
| else: |
| print("EXPERIMENT_ENABLES = {", file=B) |
| for name, exp in self._experiment_definitions.items(): |
| print( |
| f" \"{name}\": \"{','.join(sorted(self._ExperimentEnableSet(name)))}\",", |
| file=B, |
| ) |
| print("}", file=B) |
| |
| # Generate a list of experiments that use polling. |
| print(file=B) |
| if mode == "test": |
| print("TEST_EXPERIMENT_POLLERS = [", file=B) |
| else: |
| print("EXPERIMENT_POLLERS = [", file=B) |
| for name, exp in self._experiment_definitions.items(): |
| if exp._uses_polling: |
| print(f' "{name}",', file=B) |
| print("]", file=B) |
| |
| print(file=B) |
| if mode == "test": |
| print("TEST_EXPERIMENTS = {", file=B) |
| else: |
| print("EXPERIMENTS = {", file=B) |
| |
| for platform in self._platforms_define.keys(): |
| bzl_to_tags_to_experiments_platform = sorted( |
| (self._bzl_list_for_defaults[default], tags_to_experiments) |
| for default, tags_to_experiments in bzl_to_tags_to_experiments[ |
| platform |
| ].items() |
| if self._bzl_list_for_defaults[default] is not None |
| ) |
| print(' "%s": {' % platform, file=B) |
| for ( |
| key, |
| tags_to_experiments, |
| ) in bzl_to_tags_to_experiments_platform: |
| print(' "%s": {' % key, file=B) |
| for tag, experiments in sorted(tags_to_experiments.items()): |
| print(' "%s": [' % tag, file=B) |
| for experiment in sorted(experiments): |
| print(' "%s",' % experiment, file=B) |
| print(" ],", file=B) |
| print(" },", file=B) |
| print(" },", file=B) |
| print("}", file=B) |