
com.glassdoor.planout4j.NamespaceConfig Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of planout4j-core Show documentation
Show all versions of planout4j-core Show documentation
PlanOut for Java core infrastructure (mostly ported from original PlanOut)
package com.glassdoor.planout4j;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.MoreObjects;
import com.glassdoor.planout4j.planout.ops.random.RandomInteger;
import com.glassdoor.planout4j.planout.ops.random.Sample;
import static com.google.common.base.Preconditions.*;
import static java.lang.String.format;
/**
* NamespaceConfig maintains the "static" state of namespace (data that doesn't change at runtime) and includes
* - name, primary unit, optional salt
* - all experiment definitions keyed by the definition string
* - default experiment (required)
* - all active experiments keyed by name
* - collection of available segments
* - segments allocation map (which experiment a segment is assigned to)
*
* @see com.glassdoor.planout4j.config.NamespaceConfigBuilder
* @author ernest.mishkin
*/
public class NamespaceConfig {
private final Logger logger = LoggerFactory.getLogger(getClass());
private Map config;
public final String name;
public final String unit;
public final String salt;
private final Map allExpDefs = new LinkedHashMap<>();
private final Map activeExperiments = new LinkedHashMap<>();
private final Set availableSegments;
private final Experiment[] allocationMap;
private Experiment defaultExperiment;
private boolean noMoreChanges;
/**
* Constructs new namespace config with the namespace-level data.
* After construction, it is expected that [define/add/remove]Experiment methods will be called a number of times
* to complete the configuration. Once complete, this object should not be further modified.
* @param name namespace name
* @param totalSegments total number of segments to split the traffic into, must be positive int
* @param unit name of the primary unit (key in the input parameters)
* @param salt optional salt, if not specified the name is used
*/
public NamespaceConfig(final String name, final int totalSegments, final String unit, final String salt) {
checkArgument(StringUtils.isNotEmpty(name));
this.name = name;
checkArgument(StringUtils.isNotEmpty(unit));
this.unit = unit;
checkArgument(totalSegments > 0, "totalSegments must be a positive integer");
availableSegments = new LinkedHashSet<>(totalSegments, 1);
for (int i=0; i < totalSegments; i++) {
availableSegments.add(i);
}
allocationMap = new Experiment[totalSegments];
this.salt = StringUtils.stripToNull(salt);
}
/**
* Defines a new experiment. Simply associates a key with an assign script.
* Does not validate the script. The key must be unique of course.
* @param definition the key to access the script
* @param assign the assignment script (e.g. compiled PlanOut DSL or Darwin JSON)
*/
public void defineExperiment(final String definition, final Map assign) {
verifyChangesAllowed();
final ExperimentConfig expDef = new ExperimentConfig(definition, assign);
final ExperimentConfig existingDef = allExpDefs.put(definition, expDef);
checkArgument(existingDef == null, "duplicated experiment definition %s", expDef);
}
/**
* Instantiates an experiment with a given name and number of segments.
* @param expName experiment name
* @param definition definition key used in {@link #defineExperiment(String, java.util.Map)}
* @param segments number of segments to allocate, must be no more than remaining available
*/
public void addExperiment(final String expName, final String definition, final int segments) {
verifyChangesAllowed();
final ExperimentConfig expDef = allExpDefs.get(definition);
checkArgument(expDef != null, "reference to undefined experiment %s", definition);
final Collection usedSegments = allocateSegments(segments, expName);
final String expSalt = format("%s.%s", StringUtils.defaultString(salt, this.name), expName);
final Experiment exp = new Experiment(expName, expSalt, expDef, usedSegments);
final Experiment existingExp = activeExperiments.put(expName, exp);
checkArgument(existingExp == null, "duplicate experiment name %s", expName);
for (Integer segment : usedSegments) {
allocationMap[segment] = exp;
}
}
/**
* Removes an experiment and releases its segments into the available pool.
* @param expName experiment name
*/
public void removeExperiment(final String expName) {
verifyChangesAllowed();
final Experiment exp = activeExperiments.remove(expName);
checkArgument(exp != null, "No active experiment named %s", expName);
availableSegments.addAll(exp.usedSegments);
for (Integer segment : exp.usedSegments) {
// sanity check
checkState(allocationMap[segment] == exp,
"Segment %s is supposed to be allocated to experiment %s but is allocated to %s instead",
segment, exp, allocationMap[segment]);
allocationMap[segment] = null;
}
}
/**
* Set default experiment. The experiment name is same as definition key.
* @param definition the definition key
*/
public void setDefaultExperiment(final String definition) {
verifyChangesAllowed();
final ExperimentConfig expDef = allExpDefs.get(definition);
checkArgument(expDef != null, "reference to undefined experiment %s", definition);
final String expSalt = format("%s.%s", StringUtils.defaultString(salt, this.name), definition);
defaultExperiment = new Experiment(definition, expSalt, expDef, null);
}
/**
* @return number of defined experiments
*/
public int getExperimentDefsCount() {
return allExpDefs.size();
}
/**
* Get an active experiment config by its definition key (primarily for debugging purposes).
* @param definition definition key
* @return ExperimentConfig (null if none with the specified definition as key)
*/
public ExperimentConfig getExperimentConfig(final String definition) {
return allExpDefs.get(definition);
}
/**
* @return names of all experiment configs (aka definitions)
*/
public Collection getExperimentConfigNames() {
return allExpDefs.keySet();
}
/**
* @return number of active experiment instances
*/
public int getActiveExperimentsCount() {
return activeExperiments.size();
}
/**
* Get an active experiment by name (primarily for debugging purposes).
* @param name experiment name
* @return Experiment instance, null if there is no active experiment with the specified name
*/
public Experiment getActiveExperiment(final String name) {
return activeExperiments.get(name);
}
/**
* @return names of all active experiments
*/
public Collection getActiveExperimentNames() {
return activeExperiments.keySet();
}
/**
* @return total number of segments
*/
public int getTotalSegments() {
return allocationMap.length;
}
/**
* @return number of segments used
*/
public int getUsedSegments() {
return getTotalSegments() - availableSegments.size();
}
/**
* Maps the primary unit to segment and segment to experiment.
* This is the main API for {@link Namespace} class.
* @param input input context, must at least contain the entry for the primary unit
* @return Experiment allocated to the corresponding segment, may be null
*/
public Experiment getExperiment(final Map input) {
return getExperiment(getSegment(input));
}
/**
* @param segment an in between 0 and {@link #getTotalSegments()} - 1
* @return Experiment the segment is allocated to, may be null
*/
public Experiment getExperiment(final int segment) {
checkElementIndex(segment, allocationMap.length, "segment");
final Experiment experiment = allocationMap[segment];
logger.debug("segment {} belongs to experiment {}", segment, experiment);
return experiment;
}
/**
* Maps a primary unit value (e.g. user ID, a GUID cookie, etc.) to one of the valid segments.
* This is done deterministically (the underlying code utilizes hashing algo).
* @param input input map must contain an entry with the value of {@link #unit} as a key
* @return int in the range of 0 .. totalSegments-1
*/
public int getSegment(final Map input) {
checkArgument(input.containsKey(unit),
"Supplied input does not have a value for '%s' (primary unit of namespace %s)", unit, name);
final Object unitVal = input.get(unit);
final Long segment = new RandomInteger(0, getTotalSegments()-1, unitVal).eval();
logger.debug("Unit {} hashes to segment {}", unitVal, segment);
return segment.intValue();
}
/**
* @return default experiment
*/
public Experiment getDefaultExperiment() {
return defaultExperiment;
}
private Collection allocateSegments(final int segments, final String expName) {
checkArgument(segments <= availableSegments.size(),
"Experiment %s requests %s segments but only %s (out of %s) are available",
name, segments, availableSegments.size(), getTotalSegments());
final List usedSegments = new Sample<>(new ArrayList<>(availableSegments), segments, expName).eval();
availableSegments.removeAll(usedSegments);
return usedSegments;
}
public Map getConfig() {
return config;
}
public void setConfig(final Map config) {
verifyChangesAllowed();
this.config = Collections.unmodifiableMap(config);
}
private void verifyChangesAllowed() {
checkState(!noMoreChanges, "No more changes to this object!");
}
public void noMoreChanges() {
verifyChangesAllowed();
noMoreChanges = true;
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this).add("name", name).add("total segments", getTotalSegments())
.add("used segments", getUsedSegments()).add("definitions", getExperimentDefsCount())
.add("active experiments", getActiveExperimentsCount()).toString();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy