diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java index e6ef07671..52f8abf38 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java @@ -1,5 +1,19 @@ package org.whispersystems.textsecuregcm.configuration.dynamic; +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.validation.Valid; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + public class DynamicConfiguration { + @JsonProperty + @Valid + private Map experiments = Collections.emptyMap(); + + public Optional getExperimentEnrollmentConfiguration(final String experimentName) { + return Optional.ofNullable(experiments.get(experimentName)); + } } 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 new file mode 100644 index 000000000..06dbdfc79 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration.dynamic; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.validation.Valid; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public class DynamicExperimentEnrollmentConfiguration { + + @JsonProperty + @Valid + private Set enrolledUuids = Collections.emptySet(); + + @JsonProperty + @Valid + @Min(0) + @Max(100) + private int enrollmentPercentage = 0; + + public Set getEnrolledUuids() { + return enrolledUuids; + } + + public int getEnrollmentPercentage() { + return enrollmentPercentage; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java new file mode 100644 index 000000000..e709fc468 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.experiment; + +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public class ExperimentEnrollmentManager { + + private final DynamicConfigurationManager dynamicConfigurationManager; + + public ExperimentEnrollmentManager(final DynamicConfigurationManager dynamicConfigurationManager) { + this.dynamicConfigurationManager = dynamicConfigurationManager; + } + + public boolean isEnrolled(final Account account, final String experimentName) { + final Optional maybeConfiguration = dynamicConfigurationManager.getConfiguration().getExperimentEnrollmentConfiguration(experimentName); + + final Set enrolledUuids = maybeConfiguration.map(DynamicExperimentEnrollmentConfiguration::getEnrolledUuids) + .orElse(Collections.emptySet()); + + final boolean enrolled; + + if (enrolledUuids.contains(account.getUuid())) { + enrolled = true; + } else { + final int threshold = maybeConfiguration.map(DynamicExperimentEnrollmentConfiguration::getEnrollmentPercentage).orElse(0); + final int enrollmentHash = ((account.getUuid().hashCode() ^ experimentName.hashCode()) & Integer.MAX_VALUE) % 100; + + enrolled = enrollmentHash < threshold; + } + + return enrolled; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java index ff75aa207..7d28f8413 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java @@ -31,7 +31,6 @@ public class DynamicConfigurationManager implements Managed { private final String clientId; private final AmazonAppConfig appConfigClient; - private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()).configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private final AtomicReference configuration = new AtomicReference<>(); private final AtomicBoolean running = new AtomicBoolean(true); private final Logger logger = LoggerFactory.getLogger(DynamicConfigurationManager.class); @@ -40,6 +39,8 @@ public class DynamicConfigurationManager implements Managed { private boolean initialized = false; + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(new YAMLFactory()).configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + public DynamicConfigurationManager(String application, String environment, String configurationName) { this(AmazonAppConfigClient.builder() .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout(10000).withRequestTimeout(10000)) @@ -104,7 +105,7 @@ public class DynamicConfigurationManager implements Managed { if (!StringUtils.equals(lastConfigResult.getConfigurationVersion(), previousVersion)) { logger.info("Received new config version: {}", lastConfigResult.getConfigurationVersion()); - maybeDynamicConfiguration = Optional.of(mapper.readValue(StandardCharsets.UTF_8.decode(lastConfigResult.getContent().asReadOnlyBuffer()).toString(), DynamicConfiguration.class)); + maybeDynamicConfiguration = Optional.of(OBJECT_MAPPER.readValue(StandardCharsets.UTF_8.decode(lastConfigResult.getContent().asReadOnlyBuffer()).toString(), DynamicConfiguration.class)); } else { // No change since last version maybeDynamicConfiguration = Optional.empty(); 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 new file mode 100644 index 000000000..26614f86e --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration.dynamic; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.Test; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.*; + +public class DynamicConfigurationTest { + + @Test + public void testParseExperimentConfig() throws JsonProcessingException { + { + final String emptyConfigYaml = "test: true"; + final DynamicConfiguration emptyConfig = DynamicConfigurationManager.OBJECT_MAPPER.readValue(emptyConfigYaml, DynamicConfiguration.class); + + assertFalse(emptyConfig.getExperimentEnrollmentConfiguration("test").isPresent()); + } + + { + final String experimentConfigYaml = + "experiments:\n" + + " percentageOnly:\n" + + " enrollmentPercentage: 12\n" + + " uuidsAndPercentage:\n" + + " enrolledUuids:\n" + + " - 717b1c09-ed0b-4120-bb0e-f4697534b8e1\n" + + " - 279f264c-56d7-4bbf-b9da-de718ff90903\n" + + " enrollmentPercentage: 77\n" + + " uuidsOnly:\n" + + " enrolledUuids:\n" + + " - 71618739-114c-4b1f-bb0d-6478a44eb600"; + + final DynamicConfiguration config = DynamicConfigurationManager.OBJECT_MAPPER.readValue(experimentConfigYaml, DynamicConfiguration.class); + + 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()); + + assertTrue(config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").isPresent()); + assertEquals(77, config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrollmentPercentage()); + assertEquals(Set.of(UUID.fromString("717b1c09-ed0b-4120-bb0e-f4697534b8e1"), UUID.fromString("279f264c-56d7-4bbf-b9da-de718ff90903")), + config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrolledUuids()); + + assertTrue(config.getExperimentEnrollmentConfiguration("uuidsOnly").isPresent()); + assertEquals(0, config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrollmentPercentage()); + assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478a44eb600")), + config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids()); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java new file mode 100644 index 000000000..adb0135e7 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.experiment; + +import org.junit.Before; +import org.junit.Test; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ExperimentEnrollmentManagerTest { + + private DynamicExperimentEnrollmentConfiguration experimentEnrollmentConfiguration; + + private ExperimentEnrollmentManager experimentEnrollmentManager; + + private Account account; + + private static final UUID ACCOUNT_UUID = UUID.randomUUID(); + private static final String EXPERIMENT_NAME = "test"; + + @Before + public void setUp() { + final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); + final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); + + experimentEnrollmentConfiguration = mock(DynamicExperimentEnrollmentConfiguration.class); + experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + account = mock(Account.class); + + when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); + when(dynamicConfiguration.getExperimentEnrollmentConfiguration(EXPERIMENT_NAME)).thenReturn(Optional.of(experimentEnrollmentConfiguration)); + when(account.getUuid()).thenReturn(ACCOUNT_UUID); + } + + @Test + public void testIsEnrolled() { + assertFalse(experimentEnrollmentManager.isEnrolled(account, EXPERIMENT_NAME)); + assertFalse(experimentEnrollmentManager.isEnrolled(account, EXPERIMENT_NAME + "-unrelated-experiment")); + + when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Set.of(ACCOUNT_UUID)); + assertTrue(experimentEnrollmentManager.isEnrolled(account, EXPERIMENT_NAME)); + + when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Collections.emptySet()); + when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0); + + assertFalse(experimentEnrollmentManager.isEnrolled(account, EXPERIMENT_NAME)); + + when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100); + assertTrue(experimentEnrollmentManager.isEnrolled(account, EXPERIMENT_NAME)); + } +}