• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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