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 23from copy import deepcopy 24import ctypes 25import datetime 26import json 27import math 28import os 29import re 30import sys 31 32import yaml 33 34_CODEGEN_PLACEHOLDER_TEXT = """ 35This file contains the autogenerated parts of the experiments API. 36 37It generates two symbols for each experiment. 38 39For the experiment named new_car_project, it generates: 40 41- a function IsNewCarProjectEnabled() that returns true if the experiment 42 should be enabled at runtime. 43 44- a macro GRPC_EXPERIMENT_IS_INCLUDED_NEW_CAR_PROJECT that is defined if the 45 experiment *could* be enabled at runtime. 46 47The function is used to determine whether to run the experiment or 48non-experiment code path. 49 50If the experiment brings significant bloat, the macro can be used to avoid 51including the experiment code path in the binary for binaries that are size 52sensitive. 53 54By default that includes our iOS and Android builds. 55 56Finally, a small array is included that contains the metadata for each 57experiment. 58 59A macro, GRPC_EXPERIMENTS_ARE_FINAL, controls whether we fix experiment 60configuration at build time (if it's defined) or allow it to be tuned at 61runtime (if it's disabled). 62 63If you are using the Bazel build system, that macro can be configured with 64--define=grpc_experiments_are_final=true 65""" 66 67 68def _EXPERIMENTS_TEST_SKELETON(defs, test_body): 69 return f""" 70#include <grpc/support/port_platform.h> 71 72#include "test/core/experiments/fixtures/experiments.h" 73 74#include <memory> 75 76#include "gtest/gtest.h" 77 78#include "src/core/lib/experiments/config.h" 79 80#ifndef GRPC_EXPERIMENTS_ARE_FINAL 81{defs} 82TEST(ExperimentsTest, CheckExperimentValuesTest) {{ 83{test_body} 84}} 85 86#endif // GRPC_EXPERIMENTS_ARE_FINAL 87 88int main(int argc, char** argv) {{ 89 testing::InitGoogleTest(&argc, argv); 90 grpc_core::LoadTestOnlyExperimentsFromMetadata( 91 grpc_core::g_test_experiment_metadata, grpc_core::kNumTestExperiments); 92 return RUN_ALL_TESTS(); 93}} 94""" 95 96 97def _EXPERIMENTS_EXPECTED_VALUE(name, expected_value): 98 return f""" 99bool GetExperiment{name}ExpectedValue() {{ 100{expected_value} 101}} 102""" 103 104 105def _EXPERIMENT_CHECK_TEXT(name): 106 return f""" 107 ASSERT_EQ(grpc_core::Is{name}Enabled(), 108 GetExperiment{name}ExpectedValue()); 109""" 110 111 112def ToCStr(s, encoding="ascii"): 113 if isinstance(s, str): 114 s = s.encode(encoding) 115 result = "" 116 for c in s: 117 c = chr(c) if isinstance(c, int) else c 118 if not (32 <= ord(c) < 127) or c in ("\\", '"'): 119 result += "\\%03o" % ord(c) 120 else: 121 result += c 122 return '"' + result + '"' 123 124 125def SnakeToPascal(s): 126 return "".join(x.capitalize() for x in s.split("_")) 127 128 129def PutBanner(files, banner, prefix): 130 # Print a big comment block into a set of files 131 for f in files: 132 for line in banner: 133 if not line: 134 print(prefix, file=f) 135 else: 136 print("%s %s" % (prefix, line), file=f) 137 print(file=f) 138 139 140def PutCopyright(file, prefix): 141 # copy-paste copyright notice from this file 142 with open(__file__) as my_source: 143 copyright = [] 144 for line in my_source: 145 if line[0] != "#": 146 break 147 for line in my_source: 148 if line[0] == "#": 149 copyright.append(line) 150 break 151 for line in my_source: 152 if line[0] != "#": 153 break 154 copyright.append(line) 155 PutBanner([file], [line[2:].rstrip() for line in copyright], prefix) 156 157 158def AreExperimentsOrdered(experiments): 159 # Check that the experiments are ordered by name 160 for i in range(1, len(experiments)): 161 if experiments[i - 1]["name"] >= experiments[i]["name"]: 162 print( 163 "Experiments are unordered: %s should be after %s" 164 % (experiments[i - 1]["name"], experiments[i]["name"]) 165 ) 166 return False 167 return True 168 169 170class ExperimentDefinition(object): 171 def __init__(self, attributes): 172 self._error = False 173 if "name" not in attributes: 174 print("ERROR: experiment with no name: %r" % attributes) 175 self._error = True 176 if "description" not in attributes: 177 print( 178 "ERROR: no description for experiment %s" % attributes["name"] 179 ) 180 self._error = True 181 if "owner" not in attributes: 182 print("ERROR: no owner for experiment %s" % attributes["name"]) 183 self._error = True 184 if "expiry" not in attributes: 185 print("ERROR: no expiry for experiment %s" % attributes["name"]) 186 self._error = True 187 if attributes["name"] == "monitoring_experiment": 188 if attributes["expiry"] != "never-ever": 189 print("ERROR: monitoring_experiment should never expire") 190 self._error = True 191 if self._error: 192 print("Failed to create experiment definition") 193 return 194 self._allow_in_fuzzing_config = True 195 self._uses_polling = False 196 self._name = attributes["name"] 197 self._description = attributes["description"] 198 self._expiry = attributes["expiry"] 199 self._default = {} 200 self._additional_constraints = {} 201 self._test_tags = [] 202 self._requires = set() 203 204 if "uses_polling" in attributes: 205 self._uses_polling = attributes["uses_polling"] 206 207 if "allow_in_fuzzing_config" in attributes: 208 self._allow_in_fuzzing_config = attributes[ 209 "allow_in_fuzzing_config" 210 ] 211 212 if "test_tags" in attributes: 213 self._test_tags = attributes["test_tags"] 214 215 for requirement in attributes.get("requires", []): 216 self._requires.add(requirement) 217 218 def IsValid(self, check_expiry=False): 219 if self._error: 220 return False 221 if not check_expiry: 222 return True 223 if ( 224 self._name == "monitoring_experiment" 225 and self._expiry == "never-ever" 226 ): 227 return True 228 today = datetime.date.today() 229 two_quarters_from_now = today + datetime.timedelta(days=180) 230 expiry = datetime.datetime.strptime(self._expiry, "%Y/%m/%d").date() 231 if expiry < today: 232 print( 233 "WARNING: experiment %s expired on %s" 234 % (self._name, self._expiry) 235 ) 236 if expiry > two_quarters_from_now: 237 print( 238 "WARNING: experiment %s expires far in the future on %s" 239 % (self._name, self._expiry) 240 ) 241 print("expiry should be no more than two quarters from now") 242 return not self._error 243 244 def AddRolloutSpecification( 245 self, allowed_defaults, allowed_platforms, rollout_attributes 246 ): 247 if self._error: 248 return False 249 if rollout_attributes["name"] != self._name: 250 print( 251 "ERROR: Rollout specification does not apply to this" 252 " experiment: %s" % self._name 253 ) 254 return False 255 for requirement in rollout_attributes.get("requires", []): 256 self._requires.add(requirement) 257 if "default" not in rollout_attributes: 258 print( 259 "ERROR: no default for experiment %s" 260 % rollout_attributes["name"] 261 ) 262 self._error = True 263 return False 264 is_dict = isinstance(rollout_attributes["default"], dict) 265 for platform in allowed_platforms: 266 if is_dict: 267 value = rollout_attributes["default"].get(platform, False) 268 else: 269 value = rollout_attributes["default"] 270 if isinstance(value, dict): 271 self._default[platform] = "debug" 272 self._additional_constraints[platform] = value 273 elif value not in allowed_defaults: 274 print( 275 "ERROR: default for experiment %s on platform %s " 276 "is of incorrect format" 277 % (rollout_attributes["name"], platform) 278 ) 279 self._error = True 280 return False 281 else: 282 self._default[platform] = value 283 self._additional_constraints[platform] = {} 284 return True 285 286 @property 287 def name(self): 288 return self._name 289 290 @property 291 def description(self): 292 return self._description 293 294 def default(self, platform): 295 return self._default.get(platform, False) 296 297 @property 298 def test_tags(self): 299 return self._test_tags 300 301 @property 302 def allow_in_fuzzing_config(self): 303 return self._allow_in_fuzzing_config 304 305 def additional_constraints(self, platform): 306 return self._additional_constraints.get(platform, {}) 307 308 309class ExperimentsCompiler(object): 310 def __init__( 311 self, 312 defaults, 313 final_return, 314 final_define, 315 platforms_define, 316 bzl_list_for_defaults=None, 317 ): 318 self._defaults = defaults 319 self._final_return = final_return 320 self._final_define = final_define 321 self._platforms_define = platforms_define 322 self._bzl_list_for_defaults = bzl_list_for_defaults 323 self._experiment_definitions = collections.OrderedDict() 324 self._experiment_rollouts = {} 325 326 def AddExperimentDefinition(self, experiment_definition): 327 if experiment_definition.name in self._experiment_definitions: 328 print( 329 "ERROR: Duplicate experiment definition: %s" 330 % experiment_definition.name 331 ) 332 return False 333 self._experiment_definitions[ 334 experiment_definition.name 335 ] = experiment_definition 336 return True 337 338 def AddRolloutSpecification(self, rollout_attributes): 339 if "name" not in rollout_attributes: 340 print( 341 "ERROR: experiment with no name: %r in rollout_attribute" 342 % rollout_attributes 343 ) 344 return False 345 if rollout_attributes["name"] not in self._experiment_definitions: 346 print( 347 "WARNING: rollout for an undefined experiment: %s ignored" 348 % rollout_attributes["name"] 349 ) 350 return True 351 return self._experiment_definitions[ 352 rollout_attributes["name"] 353 ].AddRolloutSpecification( 354 self._defaults, self._platforms_define, rollout_attributes 355 ) 356 357 def _FinalizeExperiments(self): 358 queue = collections.OrderedDict() 359 for name, exp in self._experiment_definitions.items(): 360 queue[name] = exp._requires 361 done = set() 362 final = collections.OrderedDict() 363 while queue: 364 take = None 365 for name, requires in queue.items(): 366 if requires.issubset(done): 367 take = name 368 break 369 if take is None: 370 print("ERROR: circular dependency in experiments") 371 return False 372 done.add(take) 373 final[take] = self._experiment_definitions[take] 374 del queue[take] 375 self._experiment_definitions = final 376 return True 377 378 def _GenerateExperimentsHdrForPlatform(self, platform, file_desc): 379 for _, exp in self._experiment_definitions.items(): 380 define_fmt = self._final_define[exp.default(platform)] 381 if define_fmt: 382 print( 383 define_fmt 384 % ("GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper()), 385 file=file_desc, 386 ) 387 print( 388 "inline bool Is%sEnabled() { %s }" 389 % ( 390 SnakeToPascal(exp.name), 391 self._final_return[exp.default(platform)], 392 ), 393 file=file_desc, 394 ) 395 396 def GenerateExperimentsHdr(self, output_file, mode): 397 assert self._FinalizeExperiments() 398 with open(output_file, "w") as H: 399 PutCopyright(H, "//") 400 PutBanner( 401 [H], 402 ["Auto generated by tools/codegen/core/gen_experiments.py"] 403 + _CODEGEN_PLACEHOLDER_TEXT.splitlines(), 404 "//", 405 ) 406 407 if mode != "test": 408 include_guard = "GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H" 409 else: 410 real_output_file = output_file.replace(".github", "") 411 file_path_list = real_output_file.split("/")[0:-1] 412 file_name = real_output_file.split("/")[-1].split(".")[0] 413 414 include_guard = f"GRPC_{'_'.join(path.upper() for path in file_path_list)}_{file_name.upper()}_H" 415 416 print(f"#ifndef {include_guard}", file=H) 417 print(f"#define {include_guard}", file=H) 418 print(file=H) 419 print("#include <grpc/support/port_platform.h>", file=H) 420 print(file=H) 421 print('#include "src/core/lib/experiments/config.h"', file=H) 422 print(file=H) 423 print("namespace grpc_core {", file=H) 424 print(file=H) 425 print("#ifdef GRPC_EXPERIMENTS_ARE_FINAL", file=H) 426 idx = 0 427 for platform in sorted(self._platforms_define.keys()): 428 if platform == "posix": 429 continue 430 print( 431 f"\n#{'if' if idx ==0 else 'elif'} " 432 f"defined({self._platforms_define[platform]})", 433 file=H, 434 ) 435 self._GenerateExperimentsHdrForPlatform(platform, H) 436 idx += 1 437 print("\n#else", file=H) 438 self._GenerateExperimentsHdrForPlatform("posix", H) 439 print("#endif", file=H) 440 print("\n#else", file=H) 441 if mode == "test": 442 num_experiments_var_name = "kNumTestExperiments" 443 experiments_metadata_var_name = "g_test_experiment_metadata" 444 else: 445 num_experiments_var_name = "kNumExperiments" 446 experiments_metadata_var_name = "g_experiment_metadata" 447 print("enum ExperimentIds {", file=H) 448 for exp in self._experiment_definitions.values(): 449 print(f" kExperimentId{SnakeToPascal(exp.name)},", file=H) 450 print(f" {num_experiments_var_name}", file=H) 451 print("};", file=H) 452 for exp in self._experiment_definitions.values(): 453 print( 454 "#define GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper(), 455 file=H, 456 ) 457 print( 458 "inline bool Is%sEnabled() { return" 459 " Is%sExperimentEnabled(kExperimentId%s); }" 460 % ( 461 SnakeToPascal(exp.name), 462 "Test" if mode == "test" else "", 463 SnakeToPascal(exp.name), 464 ), 465 file=H, 466 ) 467 print(file=H) 468 print( 469 ( 470 "extern const ExperimentMetadata" 471 f" {experiments_metadata_var_name}[{num_experiments_var_name}];" 472 ), 473 file=H, 474 ) 475 print(file=H) 476 print("#endif", file=H) 477 print("} // namespace grpc_core", file=H) 478 print(file=H) 479 print(f"#endif // {include_guard}", file=H) 480 481 def _GenerateExperimentsSrcForPlatform(self, platform, mode, file_desc): 482 print("namespace {", file=file_desc) 483 have_defaults = set() 484 for _, exp in self._experiment_definitions.items(): 485 print( 486 "const char* const description_%s = %s;" 487 % (exp.name, ToCStr(exp.description)), 488 file=file_desc, 489 ) 490 print( 491 "const char* const additional_constraints_%s = %s;" 492 % ( 493 exp.name, 494 ToCStr(json.dumps(exp.additional_constraints(platform))), 495 ), 496 file=file_desc, 497 ) 498 have_defaults.add(self._defaults[exp.default(platform)]) 499 if exp._requires: 500 print( 501 "const uint8_t required_experiments_%s[] = {%s};" 502 % ( 503 exp.name, 504 ",".join( 505 f"static_cast<uint8_t>(grpc_core::kExperimentId{SnakeToPascal(name)})" 506 for name in sorted(exp._requires) 507 ), 508 ), 509 file=file_desc, 510 ) 511 if "kDefaultForDebugOnly" in have_defaults: 512 print("#ifdef NDEBUG", file=file_desc) 513 if "kDefaultForDebugOnly" in have_defaults: 514 print( 515 "const bool kDefaultForDebugOnly = false;", file=file_desc 516 ) 517 print("#else", file=file_desc) 518 if "kDefaultForDebugOnly" in have_defaults: 519 print("const bool kDefaultForDebugOnly = true;", file=file_desc) 520 print("#endif", file=file_desc) 521 print("}", file=file_desc) 522 print(file=file_desc) 523 print("namespace grpc_core {", file=file_desc) 524 print(file=file_desc) 525 if mode == "test": 526 experiments_metadata_var_name = "g_test_experiment_metadata" 527 else: 528 experiments_metadata_var_name = "g_experiment_metadata" 529 print( 530 f"const ExperimentMetadata {experiments_metadata_var_name}[] = {{", 531 file=file_desc, 532 ) 533 for _, exp in self._experiment_definitions.items(): 534 print( 535 " {%s, description_%s, additional_constraints_%s, %s, %d, %s, %s}," 536 % ( 537 ToCStr(exp.name), 538 exp.name, 539 exp.name, 540 f"required_experiments_{exp.name}" 541 if exp._requires 542 else "nullptr", 543 len(exp._requires), 544 self._defaults[exp.default(platform)], 545 "true" if exp.allow_in_fuzzing_config else "false", 546 ), 547 file=file_desc, 548 ) 549 print("};", file=file_desc) 550 print(file=file_desc) 551 print("} // namespace grpc_core", file=file_desc) 552 553 def GenerateExperimentsSrc(self, output_file, header_file_path, mode): 554 assert self._FinalizeExperiments() 555 with open(output_file, "w") as C: 556 PutCopyright(C, "//") 557 PutBanner( 558 [C], 559 ["Auto generated by tools/codegen/core/gen_experiments.py"], 560 "//", 561 ) 562 563 any_requires = False 564 for _, exp in self._experiment_definitions.items(): 565 if exp._requires: 566 any_requires = True 567 break 568 569 print("#include <grpc/support/port_platform.h>", file=C) 570 print(file=C) 571 if any_requires: 572 print("#include <stdint.h>", file=C) 573 print(file=C) 574 print( 575 f'#include "{header_file_path.replace(".github", "")}"', file=C 576 ) 577 print(file=C) 578 print("#ifndef GRPC_EXPERIMENTS_ARE_FINAL", file=C) 579 idx = 0 580 for platform in sorted(self._platforms_define.keys()): 581 if platform == "posix": 582 continue 583 print( 584 f"\n#{'if' if idx ==0 else 'elif'} " 585 f"defined({self._platforms_define[platform]})", 586 file=C, 587 ) 588 self._GenerateExperimentsSrcForPlatform(platform, mode, C) 589 idx += 1 590 print("\n#else", file=C) 591 self._GenerateExperimentsSrcForPlatform("posix", mode, C) 592 print("#endif", file=C) 593 print("#endif", file=C) 594 595 def _GenTestExperimentsExpectedValues(self, platform): 596 defs = "" 597 for _, exp in self._experiment_definitions.items(): 598 defs += _EXPERIMENTS_EXPECTED_VALUE( 599 SnakeToPascal(exp.name), 600 self._final_return[exp.default(platform)], 601 ) 602 return defs 603 604 def GenTest(self, output_file): 605 assert self._FinalizeExperiments() 606 with open(output_file, "w") as C: 607 PutCopyright(C, "//") 608 PutBanner( 609 [C], 610 ["Auto generated by tools/codegen/core/gen_experiments.py"], 611 "//", 612 ) 613 defs = "" 614 test_body = "" 615 idx = 0 616 for platform in sorted(self._platforms_define.keys()): 617 if platform == "posix": 618 continue 619 defs += ( 620 f"\n#{'if' if idx ==0 else 'elif'} " 621 f"defined({self._platforms_define[platform]})" 622 ) 623 defs += self._GenTestExperimentsExpectedValues(platform) 624 idx += 1 625 defs += "\n#else" 626 defs += self._GenTestExperimentsExpectedValues("posix") 627 defs += "#endif\n" 628 for _, exp in self._experiment_definitions.items(): 629 test_body += _EXPERIMENT_CHECK_TEXT(SnakeToPascal(exp.name)) 630 print(_EXPERIMENTS_TEST_SKELETON(defs, test_body), file=C) 631 632 def _ExperimentEnableSet(self, name): 633 s = set() 634 s.add(name) 635 for exp in self._experiment_definitions[name]._requires: 636 for req in self._ExperimentEnableSet(exp): 637 s.add(req) 638 return s 639 640 def GenExperimentsBzl(self, mode, output_file): 641 assert self._FinalizeExperiments() 642 if self._bzl_list_for_defaults is None: 643 return 644 645 defaults = dict( 646 (key, collections.defaultdict(list)) 647 for key in self._bzl_list_for_defaults.keys() 648 if key is not None 649 ) 650 651 bzl_to_tags_to_experiments = dict( 652 (platform, deepcopy(defaults)) 653 for platform in self._platforms_define.keys() 654 ) 655 656 for platform in self._platforms_define.keys(): 657 for _, exp in self._experiment_definitions.items(): 658 for tag in exp.test_tags: 659 # Search through default values for all platforms. 660 default = exp.default(platform) 661 # Interpret the debug default value as True to switch the 662 # experiment to the "on" mode. 663 if default == "debug": 664 default = True 665 bzl_to_tags_to_experiments[platform][default][tag].append( 666 exp.name 667 ) 668 669 with open(output_file, "w") as B: 670 PutCopyright(B, "#") 671 PutBanner( 672 [B], 673 ["Auto generated by tools/codegen/core/gen_experiments.py"], 674 "#", 675 ) 676 677 print( 678 ( 679 '"""Dictionary of tags to experiments so we know when to' 680 ' test different experiments."""' 681 ), 682 file=B, 683 ) 684 685 print(file=B) 686 if mode == "test": 687 print("TEST_EXPERIMENT_ENABLES = {", file=B) 688 else: 689 print("EXPERIMENT_ENABLES = {", file=B) 690 for name, exp in self._experiment_definitions.items(): 691 print( 692 f" \"{name}\": \"{','.join(sorted(self._ExperimentEnableSet(name)))}\",", 693 file=B, 694 ) 695 print("}", file=B) 696 697 # Generate a list of experiments that use polling. 698 print(file=B) 699 if mode == "test": 700 print("TEST_EXPERIMENT_POLLERS = [", file=B) 701 else: 702 print("EXPERIMENT_POLLERS = [", file=B) 703 for name, exp in self._experiment_definitions.items(): 704 if exp._uses_polling: 705 print(f' "{name}",', file=B) 706 print("]", file=B) 707 708 print(file=B) 709 if mode == "test": 710 print("TEST_EXPERIMENTS = {", file=B) 711 else: 712 print("EXPERIMENTS = {", file=B) 713 714 for platform in self._platforms_define.keys(): 715 bzl_to_tags_to_experiments_platform = sorted( 716 (self._bzl_list_for_defaults[default], tags_to_experiments) 717 for default, tags_to_experiments in bzl_to_tags_to_experiments[ 718 platform 719 ].items() 720 if self._bzl_list_for_defaults[default] is not None 721 ) 722 print(' "%s": {' % platform, file=B) 723 for ( 724 key, 725 tags_to_experiments, 726 ) in bzl_to_tags_to_experiments_platform: 727 print(' "%s": {' % key, file=B) 728 for tag, experiments in sorted(tags_to_experiments.items()): 729 print(' "%s": [' % tag, file=B) 730 for experiment in sorted(experiments): 731 print(' "%s",' % experiment, file=B) 732 print(" ],", file=B) 733 print(" },", file=B) 734 print(" },", file=B) 735 print("}", file=B) 736