1#!/usr/bin/env python3 2 3# Copyright 2023 gRPC authors. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16""" 17A module to assist in generating experiment related code and artifacts. 18""" 19 20from __future__ import print_function 21 22import collections 23import ctypes 24import datetime 25import json 26import math 27import os 28import re 29import sys 30 31import yaml 32 33_CODEGEN_PLACEHOLDER_TEXT = """ 34This file contains the autogenerated parts of the experiments API. 35 36It generates two symbols for each experiment. 37 38For the experiment named new_car_project, it generates: 39 40- a function IsNewCarProjectEnabled() that returns true if the experiment 41 should be enabled at runtime. 42 43- a macro GRPC_EXPERIMENT_IS_INCLUDED_NEW_CAR_PROJECT that is defined if the 44 experiment *could* be enabled at runtime. 45 46The function is used to determine whether to run the experiment or 47non-experiment code path. 48 49If the experiment brings significant bloat, the macro can be used to avoid 50including the experiment code path in the binary for binaries that are size 51sensitive. 52 53By default that includes our iOS and Android builds. 54 55Finally, a small array is included that contains the metadata for each 56experiment. 57 58A macro, GRPC_EXPERIMENTS_ARE_FINAL, controls whether we fix experiment 59configuration at build time (if it's defined) or allow it to be tuned at 60runtime (if it's disabled). 61 62If you are using the Bazel build system, that macro can be configured with 63--define=grpc_experiments_are_final=true 64""" 65 66 67def ToCStr(s, encoding='ascii'): 68 if isinstance(s, str): 69 s = s.encode(encoding) 70 result = '' 71 for c in s: 72 c = chr(c) if isinstance(c, int) else c 73 if not (32 <= ord(c) < 127) or c in ('\\', '"'): 74 result += '\\%03o' % ord(c) 75 else: 76 result += c 77 return '"' + result + '"' 78 79 80def SnakeToPascal(s): 81 return ''.join(x.capitalize() for x in s.split('_')) 82 83 84def PutBanner(files, banner, prefix): 85 # Print a big comment block into a set of files 86 for f in files: 87 for line in banner: 88 if not line: 89 print(prefix, file=f) 90 else: 91 print('%s %s' % (prefix, line), file=f) 92 print(file=f) 93 94 95def PutCopyright(file, prefix): 96 # copy-paste copyright notice from this file 97 with open(__file__) as my_source: 98 copyright = [] 99 for line in my_source: 100 if line[0] != '#': 101 break 102 for line in my_source: 103 if line[0] == '#': 104 copyright.append(line) 105 break 106 for line in my_source: 107 if line[0] != '#': 108 break 109 copyright.append(line) 110 PutBanner([file], [line[2:].rstrip() for line in copyright], prefix) 111 112 113class ExperimentDefinition(object): 114 115 def __init__(self, attributes): 116 self._error = False 117 if 'name' not in attributes: 118 print("ERROR: experiment with no name: %r" % attributes) 119 self._error = True 120 if 'description' not in attributes: 121 print("ERROR: no description for experiment %s" % 122 attributes['name']) 123 self._error = True 124 if 'owner' not in attributes: 125 print("ERROR: no owner for experiment %s" % attributes['name']) 126 self._error = True 127 if 'expiry' not in attributes: 128 print("ERROR: no expiry for experiment %s" % attributes['name']) 129 self._error = True 130 if attributes['name'] == 'monitoring_experiment': 131 if attributes['expiry'] != 'never-ever': 132 print("ERROR: monitoring_experiment should never expire") 133 self._error = True 134 if self._error: 135 print("Failed to create experiment definition") 136 return 137 self._allow_in_fuzzing_config = True 138 self._name = attributes['name'] 139 self._description = attributes['description'] 140 self._expiry = attributes['expiry'] 141 self._default = None 142 self._additional_constraints = {} 143 self._test_tags = [] 144 145 if 'allow_in_fuzzing_config' in attributes: 146 self._allow_in_fuzzing_config = attributes[ 147 'allow_in_fuzzing_config'] 148 149 if 'test_tags' in attributes: 150 self._test_tags = attributes['test_tags'] 151 152 def IsValid(self, check_expiry=False): 153 if self._error: 154 return False 155 if not check_expiry: 156 return True 157 if self._name == 'monitoring_experiment' and self._expiry == 'never-ever': 158 return True 159 today = datetime.date.today() 160 two_quarters_from_now = today + datetime.timedelta(days=180) 161 expiry = datetime.datetime.strptime(self._expiry, '%Y/%m/%d').date() 162 if expiry < today: 163 print("ERROR: experiment %s expired on %s" % 164 (self._name, self._expiry)) 165 self._error = True 166 if expiry > two_quarters_from_now: 167 print("ERROR: experiment %s expires far in the future on %s" % 168 (self._name, self._expiry)) 169 print("expiry should be no more than two quarters from now") 170 self._error = True 171 return not self._error 172 173 def AddRolloutSpecification(self, allowed_defaults, rollout_attributes): 174 if self._error or self._default is not None: 175 return False 176 if rollout_attributes['name'] != self._name: 177 print( 178 "ERROR: Rollout specification does not apply to this experiment: %s" 179 % self._name) 180 return False 181 if 'default' not in rollout_attributes: 182 print("ERROR: no default for experiment %s" % 183 rollout_attributes['name']) 184 self._error = True 185 if rollout_attributes['default'] not in allowed_defaults: 186 print("ERROR: invalid default for experiment %s: %r" % 187 (rollout_attributes['name'], rollout_attributes['default'])) 188 self._error = True 189 if 'additional_constraints' in rollout_attributes: 190 self._additional_constraints = rollout_attributes[ 191 'additional_constraints'] 192 self._default = rollout_attributes['default'] 193 return True 194 195 @property 196 def name(self): 197 return self._name 198 199 @property 200 def description(self): 201 return self._description 202 203 @property 204 def default(self): 205 return self._default 206 207 @property 208 def test_tags(self): 209 return self._test_tags 210 211 @property 212 def allow_in_fuzzing_config(self): 213 return self._allow_in_fuzzing_config 214 215 @property 216 def additional_constraints(self): 217 return self._additional_constraints 218 219 220class ExperimentsCompiler(object): 221 222 def __init__(self, 223 defaults, 224 final_return, 225 final_define, 226 bzl_list_for_defaults=None): 227 self._defaults = defaults 228 self._final_return = final_return 229 self._final_define = final_define 230 self._bzl_list_for_defaults = bzl_list_for_defaults 231 self._experiment_definitions = {} 232 self._experiment_rollouts = {} 233 234 def AddExperimentDefinition(self, experiment_definition): 235 if experiment_definition.name in self._experiment_definitions: 236 print("ERROR: Duplicate experiment definition: %s" % 237 experiment_definition.name) 238 return False 239 self._experiment_definitions[ 240 experiment_definition.name] = experiment_definition 241 return True 242 243 def AddRolloutSpecification(self, rollout_attributes): 244 if 'name' not in rollout_attributes: 245 print("ERROR: experiment with no name: %r in rollout_attribute" % 246 rollout_attributes) 247 return False 248 if rollout_attributes['name'] not in self._experiment_definitions: 249 print("WARNING: rollout for an undefined experiment: %s ignored" % 250 rollout_attributes['name']) 251 return (self._experiment_definitions[ 252 rollout_attributes['name']].AddRolloutSpecification( 253 self._defaults, rollout_attributes)) 254 255 def GenerateExperimentsHdr(self, output_file): 256 with open(output_file, 'w') as H: 257 PutCopyright(H, "//") 258 PutBanner( 259 [H], 260 ["Auto generated by tools/codegen/core/gen_experiments.py"] + 261 _CODEGEN_PLACEHOLDER_TEXT.splitlines(), "//") 262 263 print("#ifndef GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H", file=H) 264 print("#define GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H", file=H) 265 print(file=H) 266 print("#include <grpc/support/port_platform.h>", file=H) 267 print(file=H) 268 print("#include <stddef.h>", file=H) 269 print("#include \"src/core/lib/experiments/config.h\"", file=H) 270 print(file=H) 271 print("namespace grpc_core {", file=H) 272 print(file=H) 273 print("#ifdef GRPC_EXPERIMENTS_ARE_FINAL", file=H) 274 for _, exp in self._experiment_definitions.items(): 275 define_fmt = self._final_define[exp.default] 276 if define_fmt: 277 print(define_fmt % 278 ("GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper()), 279 file=H) 280 print( 281 "inline bool Is%sEnabled() { %s }" % 282 (SnakeToPascal(exp.name), self._final_return[exp.default]), 283 file=H) 284 print("#else", file=H) 285 for i, (_, exp) in enumerate(self._experiment_definitions.items()): 286 print("#define GRPC_EXPERIMENT_IS_INCLUDED_%s" % 287 exp.name.upper(), 288 file=H) 289 print( 290 "inline bool Is%sEnabled() { return IsExperimentEnabled(%d); }" 291 % (SnakeToPascal(exp.name), i), 292 file=H) 293 print(file=H) 294 print("constexpr const size_t kNumExperiments = %d;" % 295 len(self._experiment_definitions.keys()), 296 file=H) 297 print( 298 "extern const ExperimentMetadata g_experiment_metadata[kNumExperiments];", 299 file=H) 300 print(file=H) 301 print("#endif", file=H) 302 print("} // namespace grpc_core", file=H) 303 print(file=H) 304 print("#endif // GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H", 305 file=H) 306 307 def GenerateExperimentsSrc(self, output_file): 308 with open(output_file, 'w') as C: 309 PutCopyright(C, "//") 310 PutBanner( 311 [C], 312 ["Auto generated by tools/codegen/core/gen_experiments.py"], 313 "//") 314 315 print("#include <grpc/support/port_platform.h>", file=C) 316 print("#include \"src/core/lib/experiments/experiments.h\"", file=C) 317 print(file=C) 318 print("#ifndef GRPC_EXPERIMENTS_ARE_FINAL", file=C) 319 print("namespace {", file=C) 320 have_defaults = set() 321 for _, exp in self._experiment_definitions.items(): 322 print("const char* const description_%s = %s;" % 323 (exp.name, ToCStr(exp.description)), 324 file=C) 325 print( 326 "const char* const additional_constraints_%s = %s;" % 327 (exp.name, ToCStr(json.dumps(exp.additional_constraints))), 328 file=C) 329 have_defaults.add(self._defaults[exp.default]) 330 if 'kDefaultForDebugOnly' in have_defaults: 331 print("#ifdef NDEBUG", file=C) 332 if 'kDefaultForDebugOnly' in have_defaults: 333 print("const bool kDefaultForDebugOnly = false;", file=C) 334 print("#else", file=C) 335 if 'kDefaultForDebugOnly' in have_defaults: 336 print("const bool kDefaultForDebugOnly = true;", file=C) 337 print("#endif", file=C) 338 print("}", file=C) 339 print(file=C) 340 print("namespace grpc_core {", file=C) 341 print(file=C) 342 print("const ExperimentMetadata g_experiment_metadata[] = {", 343 file=C) 344 for _, exp in self._experiment_definitions.items(): 345 print( 346 " {%s, description_%s, additional_constraints_%s, %s, %s}," 347 % (ToCStr(exp.name), exp.name, exp.name, 348 'true' if exp.default else 'false', 349 'true' if exp.allow_in_fuzzing_config else 'false'), 350 file=C) 351 print("};", file=C) 352 print(file=C) 353 print("} // namespace grpc_core", file=C) 354 print("#endif", file=C) 355 356 def GenExperimentsBzl(self, output_file): 357 if self._bzl_list_for_defaults is None: 358 return 359 360 bzl_to_tags_to_experiments = dict( 361 (key, collections.defaultdict(list)) 362 for key in self._bzl_list_for_defaults.keys() 363 if key is not None) 364 365 for _, exp in self._experiment_definitions.items(): 366 for tag in exp.test_tags: 367 bzl_to_tags_to_experiments[exp.default][tag].append(exp.name) 368 369 with open(output_file, 'w') as B: 370 PutCopyright(B, "#") 371 PutBanner( 372 [B], 373 ["Auto generated by tools/codegen/core/gen_experiments.py"], 374 "#") 375 376 print( 377 "\"\"\"Dictionary of tags to experiments so we know when to test different experiments.\"\"\"", 378 file=B) 379 380 bzl_to_tags_to_experiments = sorted( 381 (self._bzl_list_for_defaults[default], tags_to_experiments) 382 for default, tags_to_experiments in 383 bzl_to_tags_to_experiments.items() 384 if self._bzl_list_for_defaults[default] is not None) 385 386 print(file=B) 387 print("EXPERIMENTS = {", file=B) 388 for key, tags_to_experiments in bzl_to_tags_to_experiments: 389 print(" \"%s\": {" % key, file=B) 390 for tag, experiments in sorted(tags_to_experiments.items()): 391 print(" \"%s\": [" % tag, file=B) 392 for experiment in sorted(experiments): 393 print(" \"%s\"," % experiment, file=B) 394 print(" ],", file=B) 395 print(" },", file=B) 396 print("}", file=B) 397