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