go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/experiments/experiments.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package experiments allow servers to use experimental code paths.
    16  //
    17  // An experiment is essentially like a boolean command line flag: it has a name
    18  // and it is either set or not. If it is set, the server code may do something
    19  // differently.
    20  //
    21  // The server accepts a repeated CLI flag `-enable-experiment <name>` to enable
    22  // named experiments. A typical lifecycle of an experiment is tied to
    23  // the deployment cycle:
    24  //  1. Implement the code path guarded by an experiment. It is disabled by
    25  //     default. So if someone deploys this code to production, nothing bad will
    26  //     happen.
    27  //  2. Enable the experiment on staging by passing `-enable-experiment <name>`
    28  //     flag. Verify it works.
    29  //  3. When promoting the staging to canary, enable the experiment on canary as
    30  //     well. Verify it works.
    31  //  4. Finally, enable the experiment when promoting canary to stable. Verify
    32  //     it works.
    33  //  5. At this point the experimental code path is running everywhere. Make it
    34  //     default in the code. It makes `-enable-experiment <name>` noop.
    35  //  6. When deploying this version, remove `-enable-experiment <name>` from
    36  //     deployment configs.
    37  //
    38  // The difference from command line flags:
    39  //   - An experiment is usually short lived. If it needs to stay for long, it
    40  //     should be converted into a proper command line flag.
    41  //   - The server ignores enabled experiments it doesn't know about. It
    42  //     simplifies adding and removing experiments.
    43  //   - There's better testing support.
    44  package experiments
    45  
    46  import (
    47  	"context"
    48  	"fmt"
    49  	"sync"
    50  
    51  	"go.chromium.org/luci/common/data/stringset"
    52  )
    53  
    54  // All registered experiments.
    55  var available struct {
    56  	m   sync.RWMutex
    57  	exp stringset.Set
    58  }
    59  
    60  // A context.Context key for a set of enabled experiments.
    61  var ctxKey = "go.chromium.org/luci/server/experiments"
    62  
    63  // ID identifies an experiment.
    64  //
    65  // The only way to get an ID is to call Register or GetByName.
    66  type ID struct {
    67  	name string
    68  }
    69  
    70  // String return the experiment name.
    71  func (id ID) String() string {
    72  	return id.name
    73  }
    74  
    75  // Valid is false for zero ID{} value.
    76  func (id ID) Valid() bool {
    77  	return id.name != ""
    78  }
    79  
    80  // Enabled returns true if this experiment is enabled.
    81  //
    82  // In production servers an experiment is enabled by `-enable-experiment <name>`
    83  // CLI flag.
    84  //
    85  // In tests an experiment can be enabled via Enable(ctx, id).
    86  func (id ID) Enabled(ctx context.Context) bool {
    87  	cur, _ := ctx.Value(&ctxKey).(stringset.Set)
    88  	return cur.Has(id.name)
    89  }
    90  
    91  // Register is usually called during init() to declare some experiment.
    92  //
    93  // Panics if such experiment is already registered. The package that registered
    94  // the experiment can then check if it is enabled in runtime via id.Enabled().
    95  func Register(name string) ID {
    96  	if name == "" {
    97  		panic("experiment name can't be empty")
    98  	}
    99  	available.m.Lock()
   100  	defer available.m.Unlock()
   101  	if available.exp == nil {
   102  		available.exp = stringset.New(1)
   103  	}
   104  	if !available.exp.Add(name) {
   105  		panic(fmt.Sprintf("experiment %q is already registered, pick a different name", name))
   106  	}
   107  	return ID{name}
   108  }
   109  
   110  // GetByName returns a registered experiment given its name.
   111  //
   112  // Returns ok == false if such experiment hasn't been registered.
   113  func GetByName(name string) (id ID, ok bool) {
   114  	available.m.RLock()
   115  	defer available.m.RUnlock()
   116  	if available.exp.Has(name) {
   117  		return ID{name}, true
   118  	}
   119  	return ID{}, false
   120  }
   121  
   122  // Enable enables zero or more experiments.
   123  //
   124  // In other words Enable(ctx, exp) returns a context `ctx` such that
   125  // exp.Enabled(ctx) returns true. All experiments must be registered already.
   126  //
   127  // This is an additive operation.
   128  func Enable(ctx context.Context, id ...ID) context.Context {
   129  	for _, exp := range id {
   130  		if !exp.Valid() {
   131  			panic("invalid experiment")
   132  		}
   133  	}
   134  
   135  	// Don't modify the context if the requested experiment is already enabled.
   136  	cur, _ := ctx.Value(&ctxKey).(stringset.Set)
   137  	enable := make([]string, 0, len(id))
   138  	for _, exp := range id {
   139  		if !cur.Has(exp.name) {
   140  			enable = append(enable, exp.name)
   141  		}
   142  	}
   143  	if len(enable) == 0 {
   144  		return ctx
   145  	}
   146  
   147  	// Don't mutate the existing set in the parent context, make a clone.
   148  	if cur == nil {
   149  		cur = stringset.NewFromSlice(enable...)
   150  	} else {
   151  		cur = cur.Dup()
   152  		for _, exp := range enable {
   153  			cur.Add(exp)
   154  		}
   155  	}
   156  
   157  	return context.WithValue(ctx, &ctxKey, cur)
   158  }