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 }