All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.indeed.proctor.common.StandardTestChooser Maven / Gradle / Ivy

package com.indeed.proctor.common;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.Maps;
import com.indeed.proctor.common.model.Allocation;
import com.indeed.proctor.common.model.ConsumableTestDefinition;
import com.indeed.proctor.common.model.Range;
import com.indeed.proctor.common.model.TestBucket;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.security.MessageDigest;
import java.util.List;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.el.ExpressionFactory;
import javax.el.FunctionMapper;


/**
 * Embodies the logic for a single test, including applicability rule and distribution.  {@link #choose(String, Map)} is the only useful entry point.
 * @author ketan
 */
@VisibleForTesting
class StandardTestChooser implements TestChooser {
    @Nonnull
    private final TestRangeSelector testRangeSelector;
    @Nonnull
    private final Hasher hasher;
    @Nonnull
    private final int[][] cutoffs;

    public StandardTestChooser(
            @Nonnull final ExpressionFactory expressionFactory,
            @Nonnull final FunctionMapper functionMapper,
            @Nonnull final String testName,
            @Nonnull final ConsumableTestDefinition testDefinition
    ) {
        this(new TestRangeSelector(expressionFactory, functionMapper, testName, testDefinition));
    }

    @VisibleForTesting
    StandardTestChooser(@Nonnull final TestRangeSelector selector) {
        this.testRangeSelector = selector;
        this.hasher = newHasherFor(selector);

        final ConsumableTestDefinition testDefinition = selector.getTestDefinition();

        final Map bucketValueToTest = Maps.newHashMap();
        for (final TestBucket testBucket : testDefinition.getBuckets()) {
            bucketValueToTest.put(testBucket.getValue(), testBucket);
        }

        final List allocations = testDefinition.getAllocations();
        this.cutoffs = new int[allocations.size()][];
        for (int i = 0; i < allocations.size(); i++) {
            final Allocation allocation = allocations.get(i);
            final List ranges = allocation.getRanges();
            cutoffs[i] = constructCutoffArray(allocation.getRule(), ranges);
        }
    }

    @Nonnull
    private static int[] constructCutoffArray(
            @Nullable final String rule,
            @Nonnull final List ranges
    ) {
        final int[] cutoffs = new int[ranges.size() - 1];

        double bucketTotal = 0;
        for (int i = 0; i < ranges.size(); i++) {
            bucketTotal += ranges.get(i).getLength();
            if (i < cutoffs.length) {
                cutoffs[i] = (int) (Integer.MIN_VALUE + bucketTotal * Proctor.INT_RANGE);
            }
        }

        //  I hate floating points.  TODO: extract a required precision constant/parameter?
        if (bucketTotal < 0.9999 || bucketTotal > 1.0001) { //  compensate for FP imprecision.  TODO: determine what these bounds really should be by testing stuff
            final StringBuilder sb = new StringBuilder("Buckets with rule " + rule + " don't add up to 1: ").append(ranges.get(0).getLength());
            for (int i = 1; i < ranges.size(); i++) {
                sb.append(" + ").append(ranges.get(i).getLength());
            }
            sb.append(" = ").append(bucketTotal);
            throw new IllegalArgumentException(sb.toString());
        }
        return cutoffs;
    }

    public static Hasher newHasherFor(@Nonnull final TestRangeSelector selector) {
        final String salt = Strings.nullToEmpty(selector.getTestDefinition().getSalt());

        @Nonnull final Hasher result;
        // The standard naming convention is to let the test salt be == the test name
        //  The '&' salt-prefix character is used (TEMPORARILY!) as a special flag indicated that the test name
        //  should not be a parameter to the hashing function, thus allowing multiple tests to be 'linked'
        //  through sharing the same prefixed test salt.
        //
        // TODO This test should be replaced with a definitionVersion test once all proctor-consumer applications
        //  have been updated to use a lenient parser and we can safely add to the schema.
        //
        if (salt.startsWith("&")) {
            // Newer tests use the salt-only hasher
            result = new TestSaltHasher(selector);
        } else {
            // Older tests consider the name as well
            result = new TestNameAndSaltHasher(selector);
        }

        return result;
    }

    @Nullable
    @Override
    public TestBucket choose(@Nullable final String identifier, @Nonnull final Map values) {
        final int matchingRuleIndex = testRangeSelector.findMatchingRule(values);
        if (matchingRuleIndex < 0) {
            return null;
        }

        return chooseBucket(
                cutoffs[matchingRuleIndex],
                testRangeSelector.getBucketRange(matchingRuleIndex),
                Preconditions.checkNotNull(identifier, "Missing identifier"));
    }

    private TestBucket chooseBucket(@Nonnull final int[] matchingCutoffs, final TestBucket[] matchingBucketRange, @Nonnull final String identifier) {
        final int value = hasher.hash(identifier);
        int i;
        for (i = 0; i < matchingCutoffs.length && value > matchingCutoffs[i]; i++) { /* intentionally empty */ }
        return matchingBucketRange[i];
    }

    private Map getDescriptorParameters() {
        final Map parameters = Maps.newLinkedHashMap();
        parameters.put("type", testRangeSelector.getTestDefinition().getTestType().name());
        parameters.put("salt", testRangeSelector.getTestDefinition().getSalt());
        return parameters;
    }

    @Override
    public String toString() {
        final Map parameters = getDescriptorParameters();

        final Writer sw = new StringWriter();
        final PrintWriter writer = new PrintWriter(sw);
        testRangeSelector.printTestBuckets(writer, parameters);
        return sw.toString();
    }

    @Override
    public void printTestBuckets(@Nonnull final PrintWriter writer) {
        final Map parameters = getDescriptorParameters();
        testRangeSelector.printTestBuckets(writer, parameters);
    }

    @Override
    public TestBucket getTestBucket(final int value) {
        return testRangeSelector.getTestBucket(value);
    }

    @Nonnull
    @Override
    public String[] getRules() {
        return testRangeSelector.getRules();
    }

    @Nonnull
    @Override
    public ConsumableTestDefinition getTestDefinition() {
        return testRangeSelector.getTestDefinition();
    }

    @Nonnull
    @Override
    public String getTestName() {
        return testRangeSelector.getTestName();
    }

    /**
     * @author matts
     */
    private static interface Hasher {
        public int hash(@Nonnull String identifier);
    }

    /**
     * @author matts
     */
    private abstract static class AbstractMD5Hasher implements Hasher {
        private final byte[] bytes;

        public AbstractMD5Hasher(final String salt) {
            this.bytes = salt.getBytes(Charsets.UTF_8);
        }

        @Override
        public int hash(@Nonnull final String identifier) {
            final MessageDigest md = ProctorUtils.createMessageDigest();

            md.update(bytes);
            md.update(identifier.getBytes(Charsets.UTF_8));

            final byte[] digest = md.digest();

            return convertToInt(digest);
        }

        private static int convertToInt(final byte[] digest) {
            final int offset = 12;  //  arbitrary choice; changing this would reshuffle all groups just like changing the salt
            return (0xff & digest[offset+0]) << 24 |
                    (0xff & digest[offset+1]) << 16 |
                    (0xff & digest[offset+2]) << 8 |
                    (0xff & digest[offset+3]);
        }
    }

    // Legacy salting technique
    private static class TestNameAndSaltHasher extends AbstractMD5Hasher {
        private TestNameAndSaltHasher(@Nonnull final TestRangeSelector selector) {
            super(extractSalt(selector));
        }

        private static String extractSalt(@Nonnull final TestRangeSelector selector) {
            final String testName = selector.getTestName();
            final ConsumableTestDefinition testDefinition = selector.getTestDefinition();

            return testName + "|" + testDefinition.getSalt();
        }
    }

    // Modern salting technique, allowing multiple tests to be 'linked' through use of identical hashes
    private static class TestSaltHasher extends AbstractMD5Hasher {
        private TestSaltHasher(@Nonnull final TestRangeSelector selector) {
            super(extractSalt(selector));
        }

        private static String extractSalt(@Nonnull final TestRangeSelector selector) {
            final ConsumableTestDefinition testDefinition = selector.getTestDefinition();

            return Strings.nullToEmpty(testDefinition.getSalt());
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy