diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java index bd4b0b699..04769d620 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java @@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.configuration.dynamic; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; import javax.validation.Valid; import javax.validation.constraints.Max; @@ -16,21 +17,52 @@ public class DynamicExperimentEnrollmentConfiguration { + public static class UuidSelector { + @JsonProperty @Valid - private Set enrolledUuids = Collections.emptySet(); + private Set uuids = Collections.emptySet(); + /** + * What percentage of enrolled UUIDs should the experiment be enabled for. + *

+ * Unlike {@link this#enrollmentPercentage}, this is not stable by UUID. The same UUID may be + * enrolled/unenrolled across calls. + */ @JsonProperty @Valid @Min(0) @Max(100) - private int enrollmentPercentage = 0; + private int uuidEnrollmentPercentage = 100; - public Set getEnrolledUuids() { - return enrolledUuids; + public Set getUuids() { + return uuids; } - public int getEnrollmentPercentage() { - return enrollmentPercentage; + public int getUuidEnrollmentPercentage() { + return uuidEnrollmentPercentage; } + + } + + private UuidSelector uuidSelector = new UuidSelector(); + + /** + * If the UUID is not enrolled via {@link UuidSelector#uuids}, what is the percentage chance it should be enrolled. + *

+ * This is stable by UUID, for a given configuration if a UUID is enrolled it will always be enrolled on every call. + */ + @JsonProperty + @Valid + @Min(0) + @Max(100) + private int enrollmentPercentage = 0; + + public int getEnrollmentPercentage() { + return enrollmentPercentage; + } + + public UuidSelector getUuidSelector() { + return uuidSelector; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java index e9f09c354..b06267d7c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java @@ -6,7 +6,10 @@ package org.whispersystems.textsecuregcm.experiment; import java.util.Optional; +import java.util.Random; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import com.google.common.annotations.VisibleForTesting; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPreRegistrationExperimentEnrollmentConfiguration; @@ -16,9 +19,20 @@ public class ExperimentEnrollmentManager { private final DynamicConfigurationManager dynamicConfigurationManager; + private final Random random; - public ExperimentEnrollmentManager(final DynamicConfigurationManager dynamicConfigurationManager) { + + public ExperimentEnrollmentManager( + final DynamicConfigurationManager dynamicConfigurationManager) { + this(dynamicConfigurationManager, ThreadLocalRandom.current()); + } + + @VisibleForTesting + ExperimentEnrollmentManager( + final DynamicConfigurationManager dynamicConfigurationManager, + final Random random) { this.dynamicConfigurationManager = dynamicConfigurationManager; + this.random = random; } public boolean isEnrolled(final UUID accountUuid, final String experimentName) { @@ -28,8 +42,9 @@ public boolean isEnrolled(final UUID accountUuid, final String experimentName) { return maybeConfiguration.map(config -> { - if (config.getEnrolledUuids().contains(accountUuid)) { - return true; + if (config.getUuidSelector().getUuids().contains(accountUuid)) { + final int r = random.nextInt(100); + return r < config.getUuidSelector().getUuidEnrollmentPercentage(); } return isEnrolled(accountUuid, config.getEnrollmentPercentage(), experimentName); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java index 20bce94bc..6b69ea71a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java @@ -50,16 +50,25 @@ void testParseExperimentConfig() throws JsonProcessingException { percentageOnly: enrollmentPercentage: 12 uuidsAndPercentage: - enrolledUuids: - - 717b1c09-ed0b-4120-bb0e-f4697534b8e1 - - 279f264c-56d7-4bbf-b9da-de718ff90903 + uuidSelector: + uuids: + - 717b1c09-ed0b-4120-bb0e-f4697534b8e1 + - 279f264c-56d7-4bbf-b9da-de718ff90903 enrollmentPercentage: 77 uuidsOnly: - enrolledUuids: + uuidSelector: + uuids: - 71618739-114c-4b1f-bb0d-6478a44eb600 uuids-with-dash: - enrolledUuids: - - 71618739-114c-4b1f-bb0d-6478ffffffff + uuidSelector: + uuids: + - 71618739-114c-4b1f-bb0d-6478ffffffff + uuidsAndSubSelection: + uuidSelector: + uuids: + - 6664224c-20cc-45a0-829b-95059e8a04f5 + uuidEnrollmentPercentage: 91 + enrollmentPercentage: 71 """); final DynamicConfiguration config = @@ -67,27 +76,35 @@ void testParseExperimentConfig() throws JsonProcessingException { assertFalse(config.getExperimentEnrollmentConfiguration("unconfigured").isPresent()); - assertTrue(config.getExperimentEnrollmentConfiguration("percentageOnly").isPresent()); - assertEquals(12, config.getExperimentEnrollmentConfiguration("percentageOnly").get().getEnrollmentPercentage()); - assertEquals(Collections.emptySet(), - config.getExperimentEnrollmentConfiguration("percentageOnly").get().getEnrolledUuids()); + final DynamicExperimentEnrollmentConfiguration percentageOnly = config.getExperimentEnrollmentConfiguration("percentageOnly").orElseThrow(); + assertEquals(12, percentageOnly.getEnrollmentPercentage()); + assertEquals(Collections.emptySet(), percentageOnly.getUuidSelector().getUuids()); + assertEquals(100, percentageOnly.getUuidSelector().getUuidEnrollmentPercentage()); - assertTrue(config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").isPresent()); - assertEquals(77, - config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrollmentPercentage()); + final DynamicExperimentEnrollmentConfiguration uuidsAndPercentage = config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").orElseThrow(); + assertEquals(77, uuidsAndPercentage.getEnrollmentPercentage()); assertEquals(Set.of(UUID.fromString("717b1c09-ed0b-4120-bb0e-f4697534b8e1"), UUID.fromString("279f264c-56d7-4bbf-b9da-de718ff90903")), - config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrolledUuids()); + uuidsAndPercentage.getUuidSelector().getUuids()); + assertEquals(100, uuidsAndPercentage.getUuidSelector().getUuidEnrollmentPercentage()); - assertTrue(config.getExperimentEnrollmentConfiguration("uuidsOnly").isPresent()); - assertEquals(0, config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrollmentPercentage()); + final DynamicExperimentEnrollmentConfiguration uuidsOnly = config.getExperimentEnrollmentConfiguration("uuidsOnly").orElseThrow(); + assertEquals(0, uuidsOnly.getEnrollmentPercentage()); assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478a44eb600")), - config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids()); + uuidsOnly.getUuidSelector().getUuids()); + assertEquals(100, uuidsOnly.getUuidSelector().getUuidEnrollmentPercentage()); - assertTrue(config.getExperimentEnrollmentConfiguration("uuids-with-dash").isPresent()); - assertEquals(0, config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrollmentPercentage()); + final DynamicExperimentEnrollmentConfiguration uuidsWithDash = config.getExperimentEnrollmentConfiguration("uuids-with-dash").orElseThrow(); + assertEquals(0, uuidsWithDash.getEnrollmentPercentage()); assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478ffffffff")), - config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrolledUuids()); + uuidsWithDash.getUuidSelector().getUuids()); + assertEquals(100, uuidsWithDash.getUuidSelector().getUuidEnrollmentPercentage()); + + final DynamicExperimentEnrollmentConfiguration uuidsAndSubSelection = config.getExperimentEnrollmentConfiguration("uuidsAndSubSelection").orElseThrow(); + assertEquals(71, uuidsAndSubSelection.getEnrollmentPercentage()); + assertEquals(Set.of(UUID.fromString("6664224c-20cc-45a0-829b-95059e8a04f5")), + uuidsAndSubSelection.getUuidSelector().getUuids()); + assertEquals(91, uuidsAndSubSelection.getUuidSelector().getUuidEnrollmentPercentage()); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java index 9f079afb6..30fa3f720 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java @@ -9,12 +9,18 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.Map; import java.util.Optional; +import java.util.Random; import java.util.Set; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,12 +35,14 @@ class ExperimentEnrollmentManagerTest { + private DynamicExperimentEnrollmentConfiguration.UuidSelector uuidSelector; private DynamicExperimentEnrollmentConfiguration experimentEnrollmentConfiguration; private DynamicPreRegistrationExperimentEnrollmentConfiguration preRegistrationExperimentEnrollmentConfiguration; private ExperimentEnrollmentManager experimentEnrollmentManager; private Account account; + private Random random; private static final UUID ACCOUNT_UUID = UUID.randomUUID(); private static final String UUID_EXPERIMENT_NAME = "uuid_test"; @@ -47,10 +55,14 @@ class ExperimentEnrollmentManagerTest { void setUp() { final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); + random = spy(new Random()); + experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager, random); - experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + uuidSelector = mock(DynamicExperimentEnrollmentConfiguration.UuidSelector.class); + when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100); experimentEnrollmentConfiguration = mock(DynamicExperimentEnrollmentConfiguration.class); + when(experimentEnrollmentConfiguration.getUuidSelector()).thenReturn(uuidSelector); preRegistrationExperimentEnrollmentConfiguration = mock( DynamicPreRegistrationExperimentEnrollmentConfiguration.class); @@ -70,10 +82,10 @@ void testIsEnrolled_UuidExperiment() { assertFalse( experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME + "-unrelated-experiment")); - when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Set.of(ACCOUNT_UUID)); + when(uuidSelector.getUuids()).thenReturn(Set.of(ACCOUNT_UUID)); assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); - when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Collections.emptySet()); + when(uuidSelector.getUuids()).thenReturn(Collections.emptySet()); when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0); assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); @@ -82,6 +94,24 @@ void testIsEnrolled_UuidExperiment() { assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); } + @Test + void testIsEnrolled_UuidExperimentPercentage() { + when(uuidSelector.getUuids()).thenReturn(Set.of(ACCOUNT_UUID)); + when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(0); + assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); + when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100); + assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); + + when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(75); + final Map counts = IntStream.range(0, 100).mapToObj(i -> { + when(random.nextInt(100)).thenReturn(i); + return experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME); + }) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + assertEquals(25, counts.get(false)); + assertEquals(75, counts.get(true)); + } + @ParameterizedTest @MethodSource void testIsEnrolled_PreRegistrationExperiment(final String e164, final String experimentName, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java index f0e3a9c28..1c9cb05c2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java @@ -27,8 +27,13 @@ private static DynamicConfigurationManager withEnrollment( when(dcm.getConfiguration()).thenReturn(dc); final DynamicExperimentEnrollmentConfiguration exp = mock(DynamicExperimentEnrollmentConfiguration.class); when(dc.getExperimentEnrollmentConfiguration(experimentName)).thenReturn(Optional.of(exp)); - when(exp.getEnrolledUuids()).thenReturn(enrolledUuids); + final DynamicExperimentEnrollmentConfiguration.UuidSelector uuidSelector = + mock(DynamicExperimentEnrollmentConfiguration.UuidSelector.class); + when(exp.getUuidSelector()).thenReturn(uuidSelector); + when(exp.getEnrollmentPercentage()).thenReturn(enrollmentPercentage); + when(uuidSelector.getUuids()).thenReturn(enrolledUuids); + when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100); return dcm; }