• 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 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