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 ( 222 self._name == "monitoring_experiment" 223 and self._expiry == "never-ever" 224 ): 225 return True 226 expiry = datetime.datetime.strptime(self._expiry, "%Y/%m/%d").date() 227 if ( 228 expiry.month == 11 229 or expiry.month == 12 230 or (expiry.month == 1 and expiry.day < 15) 231 ): 232 print( 233 "For experiment %s: Experiment expiration is not allowed between Nov 1 and Jan 15 (experiment lists %s)." 234 % (self._name, self._expiry) 235 ) 236 self._error = True 237 return False 238 if not check_expiry: 239 return True 240 today = datetime.date.today() 241 two_quarters_from_now = today + datetime.timedelta(days=180) 242 if expiry < today: 243 print( 244 "WARNING: experiment %s expired on %s" 245 % (self._name, self._expiry) 246 ) 247 if expiry > two_quarters_from_now: 248 print( 249 "WARNING: experiment %s expires far in the future on %s" 250 % (self._name, self._expiry) 251 ) 252 print("expiry should be no more than two quarters from now") 253 return not self._error 254 255 def AddRolloutSpecification( 256 self, allowed_defaults, allowed_platforms, rollout_attributes 257 ): 258 if self._error: 259 return False 260 if rollout_attributes["name"] != self._name: 261 print( 262 "ERROR: Rollout specification does not apply to this" 263 " experiment: %s" % self._name 264 ) 265 return False 266 for requirement in rollout_attributes.get("requires", []): 267 self._requires.add(requirement) 268 if "default" not in rollout_attributes: 269 print( 270 "ERROR: no default for experiment %s" 271 % rollout_attributes["name"] 272 ) 273 self._error = True 274 return False 275 for platform in allowed_platforms: 276 if isinstance(rollout_attributes["default"], dict): 277 value = rollout_attributes["default"].get(platform, False) 278 if isinstance(value, dict): 279 # debug is assumed for all rollouts with additional constraints 280 self._default[platform] = "debug" 281 self._additional_constraints[platform] = value 282 continue 283 else: 284 value = rollout_attributes["default"] 285 if value not in allowed_defaults: 286 print( 287 "ERROR: default for experiment %s on platform %s " 288 "is of incorrect format" 289 % (rollout_attributes["name"], platform) 290 ) 291 self._error = True 292 return False 293 self._default[platform] = value 294 self._additional_constraints[platform] = {} 295 return True 296 297 @property 298 def name(self): 299 return self._name 300 301 @property 302 def description(self): 303 return self._description 304 305 def default(self, platform): 306 return self._default.get(platform, False) 307 308 @property 309 def test_tags(self): 310 return self._test_tags 311 312 @property 313 def allow_in_fuzzing_config(self): 314 return self._allow_in_fuzzing_config 315 316 def additional_constraints(self, platform): 317 return self._additional_constraints.get(platform, {}) 318 319 320class ExperimentsCompiler(object): 321 def __init__( 322 self, 323 defaults, 324 final_return, 325 final_define, 326 platforms_define, 327 bzl_list_for_defaults=None, 328 ): 329 self._defaults = defaults 330 self._final_return = final_return 331 self._final_define = final_define 332 self._platforms_define = platforms_define 333 self._bzl_list_for_defaults = bzl_list_for_defaults 334 self._experiment_definitions = collections.OrderedDict() 335 self._experiment_rollouts = {} 336 337 def AddExperimentDefinition(self, experiment_definition): 338 if experiment_definition.name in self._experiment_definitions: 339 print( 340 "ERROR: Duplicate experiment definition: %s" 341 % experiment_definition.name 342 ) 343 return False 344 self._experiment_definitions[ 345 experiment_definition.name 346 ] = experiment_definition 347 return True 348 349 def AddRolloutSpecification(self, rollout_attributes): 350 if "name" not in rollout_attributes: 351 print( 352 "ERROR: experiment with no name: %r in rollout_attribute" 353 % rollout_attributes 354 ) 355 return False 356 if rollout_attributes["name"] not in self._experiment_definitions: 357 print( 358 "WARNING: rollout for an undefined experiment: %s ignored" 359 % rollout_attributes["name"] 360 ) 361 return True 362 return self._experiment_definitions[ 363 rollout_attributes["name"] 364 ].AddRolloutSpecification( 365 self._defaults, self._platforms_define, rollout_attributes 366 ) 367 368 def _FinalizeExperiments(self): 369 queue = collections.OrderedDict() 370 for name, exp in self._experiment_definitions.items(): 371 queue[name] = exp._requires 372 done = set() 373 final = collections.OrderedDict() 374 while queue: 375 take = None 376 for name, requires in queue.items(): 377 if requires.issubset(done): 378 take = name 379 break 380 if take is None: 381 print("ERROR: circular dependency in experiments") 382 return False 383 done.add(take) 384 final[take] = self._experiment_definitions[take] 385 del queue[take] 386 self._experiment_definitions = final 387 return True 388 389 def _GenerateExperimentsHdrForPlatform(self, platform, file_desc): 390 for _, exp in self._experiment_definitions.items(): 391 define_fmt = self._final_define[exp.default(platform)] 392 if define_fmt: 393 print( 394 define_fmt 395 % ("GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper()), 396 file=file_desc, 397 ) 398 print( 399 "inline bool Is%sEnabled() { %s }" 400 % ( 401 SnakeToPascal(exp.name), 402 self._final_return[exp.default(platform)], 403 ), 404 file=file_desc, 405 ) 406 407 def GenerateExperimentsHdr(self, output_file, mode): 408 assert self._FinalizeExperiments() 409 with open(output_file, "w") as H: 410 PutCopyright(H, "//") 411 PutBanner( 412 [H], 413 ["Auto generated by tools/codegen/core/gen_experiments.py"] 414 + _CODEGEN_PLACEHOLDER_TEXT.splitlines(), 415 "//", 416 ) 417 418 if mode != "test": 419 include_guard = "GRPC_SRC_CORE_LIB_EXPERIMENTS_EXPERIMENTS_H" 420 else: 421 real_output_file = output_file.replace(".github", "") 422 file_path_list = real_output_file.split("/")[0:-1] 423 file_name = real_output_file.split("/")[-1].split(".")[0] 424 425 include_guard = f"GRPC_{'_'.join(path.upper() for path in file_path_list)}_{file_name.upper()}_H" 426 427 print(f"#ifndef {include_guard}", file=H) 428 print(f"#define {include_guard}", file=H) 429 print(file=H) 430 print("#include <grpc/support/port_platform.h>", file=H) 431 print(file=H) 432 print('#include "src/core/lib/experiments/config.h"', file=H) 433 print(file=H) 434 print("namespace grpc_core {", file=H) 435 print(file=H) 436 print("#ifdef GRPC_EXPERIMENTS_ARE_FINAL", file=H) 437 idx = 0 438 for platform in sorted(self._platforms_define.keys()): 439 if platform == "posix": 440 continue 441 print( 442 f"\n#{'if' if idx ==0 else 'elif'} " 443 f"defined({self._platforms_define[platform]})", 444 file=H, 445 ) 446 self._GenerateExperimentsHdrForPlatform(platform, H) 447 idx += 1 448 print("\n#else", file=H) 449 self._GenerateExperimentsHdrForPlatform("posix", H) 450 print("#endif", file=H) 451 print("\n#else", file=H) 452 if mode == "test": 453 num_experiments_var_name = "kNumTestExperiments" 454 experiments_metadata_var_name = "g_test_experiment_metadata" 455 else: 456 num_experiments_var_name = "kNumExperiments" 457 experiments_metadata_var_name = "g_experiment_metadata" 458 print("enum ExperimentIds {", file=H) 459 for exp in self._experiment_definitions.values(): 460 print(f" kExperimentId{SnakeToPascal(exp.name)},", file=H) 461 print(f" {num_experiments_var_name}", file=H) 462 print("};", file=H) 463 for exp in self._experiment_definitions.values(): 464 print( 465 "#define GRPC_EXPERIMENT_IS_INCLUDED_%s" % exp.name.upper(), 466 file=H, 467 ) 468 print( 469 "inline bool Is%sEnabled() { return" 470 " Is%sExperimentEnabled<kExperimentId%s>(); }" 471 % ( 472 SnakeToPascal(exp.name), 473 "Test" if mode == "test" else "", 474 SnakeToPascal(exp.name), 475 ), 476 file=H, 477 ) 478 print(file=H) 479 print( 480 ( 481 "extern const ExperimentMetadata" 482 f" {experiments_metadata_var_name}[{num_experiments_var_name}];" 483 ), 484 file=H, 485 ) 486 print(file=H) 487 print("#endif", file=H) 488 print("} // namespace grpc_core", file=H) 489 print(file=H) 490 print(f"#endif // {include_guard}", file=H) 491 492 def _GenerateExperimentsSrcForPlatform(self, platform, mode, file_desc): 493 print("namespace {", file=file_desc) 494 have_defaults = set() 495 for _, exp in self._experiment_definitions.items(): 496 print( 497 "const char* const description_%s = %s;" 498 % (exp.name, ToCStr(exp.description)), 499 file=file_desc, 500 ) 501 print( 502 "const char* const additional_constraints_%s = %s;" 503 % ( 504 exp.name, 505 ToCStr(json.dumps(exp.additional_constraints(platform))), 506 ), 507 file=file_desc, 508 ) 509 have_defaults.add(self._defaults[exp.default(platform)]) 510 if exp._requires: 511 print( 512 "const uint8_t required_experiments_%s[] = {%s};" 513 % ( 514 exp.name, 515 ",".join( 516 f"static_cast<uint8_t>(grpc_core::kExperimentId{SnakeToPascal(name)})" 517 for name in sorted(exp._requires) 518 ), 519 ), 520 file=file_desc, 521 ) 522 if "kDefaultForDebugOnly" in have_defaults: 523 print("#ifdef NDEBUG", file=file_desc) 524 if "kDefaultForDebugOnly" in have_defaults: 525 print( 526 "const bool kDefaultForDebugOnly = false;", file=file_desc 527 ) 528 print("#else", file=file_desc) 529 if "kDefaultForDebugOnly" in have_defaults: 530 print("const bool kDefaultForDebugOnly = true;", file=file_desc) 531 print("#endif", file=file_desc) 532 print("}", file=file_desc) 533 print(file=file_desc) 534 print("namespace grpc_core {", file=file_desc) 535 print(file=file_desc) 536 if mode == "test": 537 experiments_metadata_var_name = "g_test_experiment_metadata" 538 else: 539 experiments_metadata_var_name = "g_experiment_metadata" 540 print( 541 f"const ExperimentMetadata {experiments_metadata_var_name}[] = {{", 542 file=file_desc, 543 ) 544 for _, exp in self._experiment_definitions.items(): 545 print( 546 " {%s, description_%s, additional_constraints_%s, %s, %d, %s, %s}," 547 % ( 548 ToCStr(exp.name), 549 exp.name, 550 exp.name, 551 f"required_experiments_{exp.name}" 552 if exp._requires 553 else "nullptr", 554 len(exp._requires), 555 self._defaults[exp.default(platform)], 556 "true" if exp.allow_in_fuzzing_config else "false", 557 ), 558 file=file_desc, 559 ) 560 print("};", file=file_desc) 561 print(file=file_desc) 562 print("} // namespace grpc_core", file=file_desc) 563 564 def GenerateExperimentsSrc(self, output_file, header_file_path, mode): 565 assert self._FinalizeExperiments() 566 with open(output_file, "w") as C: 567 PutCopyright(C, "//") 568 PutBanner( 569 [C], 570 ["Auto generated by tools/codegen/core/gen_experiments.py"], 571 "//", 572 ) 573 574 any_requires = False 575 for _, exp in self._experiment_definitions.items(): 576 if exp._requires: 577 any_requires = True 578 break 579 580 print("#include <grpc/support/port_platform.h>", file=C) 581 print(file=C) 582 if any_requires: 583 print("#include <stdint.h>", file=C) 584 print(file=C) 585 print( 586 f'#include "{header_file_path.replace(".github", "")}"', file=C 587 ) 588 print(file=C) 589 print("#ifndef GRPC_EXPERIMENTS_ARE_FINAL", file=C) 590 idx = 0 591 for platform in sorted(self._platforms_define.keys()): 592 if platform == "posix": 593 continue 594 print( 595 f"\n#{'if' if idx ==0 else 'elif'} " 596 f"defined({self._platforms_define[platform]})", 597 file=C, 598 ) 599 self._GenerateExperimentsSrcForPlatform(platform, mode, C) 600 idx += 1 601 print("\n#else", file=C) 602 self._GenerateExperimentsSrcForPlatform("posix", mode, C) 603 print("#endif", file=C) 604 print("#endif", file=C) 605 606 def _GenTestExperimentsExpectedValues(self, platform): 607 defs = "" 608 for _, exp in self._experiment_definitions.items(): 609 defs += _EXPERIMENTS_EXPECTED_VALUE( 610 SnakeToPascal(exp.name), 611 self._final_return[exp.default(platform)], 612 ) 613 return defs 614 615 def GenTest(self, output_file): 616 assert self._FinalizeExperiments() 617 with open(output_file, "w") as C: 618 PutCopyright(C, "//") 619 PutBanner( 620 [C], 621 ["Auto generated by tools/codegen/core/gen_experiments.py"], 622 "//", 623 ) 624 defs = "" 625 test_body = "" 626 idx = 0 627 for platform in sorted(self._platforms_define.keys()): 628 if platform == "posix": 629 continue 630 defs += ( 631 f"\n#{'if' if idx ==0 else 'elif'} " 632 f"defined({self._platforms_define[platform]})" 633 ) 634 defs += self._GenTestExperimentsExpectedValues(platform) 635 idx += 1 636 defs += "\n#else" 637 defs += self._GenTestExperimentsExpectedValues("posix") 638 defs += "#endif\n" 639 for _, exp in self._experiment_definitions.items(): 640 test_body += _EXPERIMENT_CHECK_TEXT(SnakeToPascal(exp.name)) 641 print(_EXPERIMENTS_TEST_SKELETON(defs, test_body), file=C) 642 643 def _ExperimentEnableSet(self, name): 644 s = set() 645 s.add(name) 646 for exp in self._experiment_definitions[name]._requires: 647 for req in self._ExperimentEnableSet(exp): 648 s.add(req) 649 return s 650 651 def EnsureNoDebugExperiments(self): 652 for name, exp in self._experiment_definitions.items(): 653 for platform, default in exp._default.items(): 654 if default == "debug": 655 raise ValueError( 656 f"Debug experiments are prohibited. '{name}' is configured with {exp._default}" 657 ) 658 659 def GenExperimentsBzl(self, mode, output_file): 660 assert self._FinalizeExperiments() 661 if self._bzl_list_for_defaults is None: 662 return 663 664 defaults = dict( 665 (key, collections.defaultdict(list)) 666 for key in self._bzl_list_for_defaults.keys() 667 if key is not None 668 ) 669 670 bzl_to_tags_to_experiments = dict( 671 (platform, deepcopy(defaults)) 672 for platform in self._platforms_define.keys() 673 ) 674 675 for platform in self._platforms_define.keys(): 676 for _, exp in self._experiment_definitions.items(): 677 for tag in exp.test_tags: 678 # Search through default values for all platforms. 679 default = exp.default(platform) 680 # Interpret the debug default value as True to switch the 681 # experiment to the "on" mode. 682 if default == "debug": 683 default = True 684 bzl_to_tags_to_experiments[platform][default][tag].append( 685 exp.name 686 ) 687 688 with open(output_file, "w") as B: 689 PutCopyright(B, "#") 690 PutBanner( 691 [B], 692 ["Auto generated by tools/codegen/core/gen_experiments.py"], 693 "#", 694 ) 695 696 print( 697 ( 698 '"""Dictionary of tags to experiments so we know when to' 699 ' test different experiments."""' 700 ), 701 file=B, 702 ) 703 704 print(file=B) 705 if mode == "test": 706 print("TEST_EXPERIMENT_ENABLES = {", file=B) 707 else: 708 print("EXPERIMENT_ENABLES = {", file=B) 709 for name, exp in self._experiment_definitions.items(): 710 print( 711 f" \"{name}\": \"{','.join(sorted(self._ExperimentEnableSet(name)))}\",", 712 file=B, 713 ) 714 print("}", file=B) 715 716 # Generate a list of experiments that use polling. 717 print(file=B) 718 if mode == "test": 719 print("TEST_EXPERIMENT_POLLERS = [", file=B) 720 else: 721 print("EXPERIMENT_POLLERS = [", file=B) 722 for name, exp in self._experiment_definitions.items(): 723 if exp._uses_polling: 724 print(f' "{name}",', file=B) 725 print("]", file=B) 726 727 print(file=B) 728 if mode == "test": 729 print("TEST_EXPERIMENTS = {", file=B) 730 else: 731 print("EXPERIMENTS = {", file=B) 732 733 for platform in self._platforms_define.keys(): 734 bzl_to_tags_to_experiments_platform = sorted( 735 (self._bzl_list_for_defaults[default], tags_to_experiments) 736 for default, tags_to_experiments in bzl_to_tags_to_experiments[ 737 platform 738 ].items() 739 if self._bzl_list_for_defaults[default] is not None 740 ) 741 print(' "%s": {' % platform, file=B) 742 for ( 743 key, 744 tags_to_experiments, 745 ) in bzl_to_tags_to_experiments_platform: 746 print(' "%s": {' % key, file=B) 747 for tag, experiments in sorted(tags_to_experiments.items()): 748 print(' "%s": [' % tag, file=B) 749 for experiment in sorted(experiments): 750 print(' "%s",' % experiment, file=B) 751 print(" ],", file=B) 752 print(" },", file=B) 753 print(" },", file=B) 754 print("}", file=B) 755