blob: 1bd5e4656483f1f2f9727a695f88858e20113ded [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package experiments allow servers to use experimental code paths.
//
// An experiment is essentially like a boolean command line flag: it has a name
// and it is either set or not. If it is set, the server code may do something
// differently.
//
// The server accepts a repeated CLI flag `-enable-experiment <name>` to enable
// named experiments. A typical lifecycle of an experiment is tied to
// the deployment cycle:
// 1. Implement the code path guarded by an experiment. It is disabled by
// default. So if someone deploys this code to production, nothing bad will
// happen.
// 2. Enable the experiment on staging by passing `-enable-experiment <name>`
// flag. Verify it works.
// 3. When promoting the staging to canary, enable the experiment on canary as
// well. Verify it works.
// 4. Finally, enable the experiment when promoting canary to stable. Verify
// it works.
// 5. At this point the experimental code path is running everywhere. Make it
// default in the code. It makes `-enable-experiment <name>` noop.
// 6. When deploying this version, remove `-enable-experiment <name>` from
// deployment configs.
//
// The difference from command line flags:
// * An experiment is usually short lived. If it needs to stay for long, it
// should be converted into a proper command line flag.
// * The server ignores enabled experiments it doesn't know about. It
// simplifies adding and removing experiments.
// * There's better testing support.
package experiments
import (
"context"
"fmt"
"sync"
"go.chromium.org/luci/common/data/stringset"
)
// All registered experiments.
var available struct {
m sync.RWMutex
exp stringset.Set
}
// A context.Context key for a set of enabled experiments.
var ctxKey = "go.chromium.org/luci/server/experiments"
// ID identifies an experiment.
//
// The only way to get an ID is to call Register or GetByName.
type ID struct {
name string
}
// String return the experiment name.
func (id ID) String() string {
return id.name
}
// Valid is false for zero ID{} value.
func (id ID) Valid() bool {
return id.name != ""
}
// Enabled returns true if this experiment is enabled.
//
// In production servers an experiment is enabled by `-enable-experiment <name>`
// CLI flag.
//
// In tests an experiment can be enabled via Enable(ctx, id).
func (id ID) Enabled(ctx context.Context) bool {
cur, _ := ctx.Value(&ctxKey).(stringset.Set)
return cur.Has(id.name)
}
// Register is usually called during init() to declare some experiment.
//
// Panics if such experiment is already registered. The package that registered
// the experiment can then check if it is enabled in runtime via id.Enabled().
func Register(name string) ID {
if name == "" {
panic("experiment name can't be empty")
}
available.m.Lock()
defer available.m.Unlock()
if available.exp == nil {
available.exp = stringset.New(1)
}
if !available.exp.Add(name) {
panic(fmt.Sprintf("experiment %q is already registered, pick a different name", name))
}
return ID{name}
}
// GetByName returns a registered experiment given its name.
//
// Returns ok == false if such experiment hasn't been registered.
func GetByName(name string) (id ID, ok bool) {
available.m.RLock()
defer available.m.RUnlock()
if available.exp.Has(name) {
return ID{name}, true
}
return ID{}, false
}
// Enable enables zero or more experiments.
//
// In other words Enable(ctx, exp) returns a context `ctx` such that
// exp.Enabled(ctx) returns true. All experiments must be registered already.
//
// This is an additive operation.
func Enable(ctx context.Context, id ...ID) context.Context {
for _, exp := range id {
if !exp.Valid() {
panic("invalid experiment")
}
}
// Don't modify the context if the requested experiment is already enabled.
cur, _ := ctx.Value(&ctxKey).(stringset.Set)
enable := make([]string, 0, len(id))
for _, exp := range id {
if !cur.Has(exp.name) {
enable = append(enable, exp.name)
}
}
if len(enable) == 0 {
return ctx
}
// Don't mutate the existing set in the parent context, make a clone.
if cur == nil {
cur = stringset.NewFromSlice(enable...)
} else {
cur = cur.Dup()
for _, exp := range enable {
cur.Add(exp)
}
}
return context.WithValue(ctx, &ctxKey, cur)
}