Add an experiment enrollment manager.
This commit is contained in:
		
							parent
							
								
									92f6a79e1f
								
							
						
					
					
						commit
						35fc98a188
					
				| 
						 | 
					@ -1,5 +1,19 @@
 | 
				
			||||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
 | 
					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 {
 | 
					public class DynamicConfiguration {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @JsonProperty
 | 
				
			||||||
 | 
					  @Valid
 | 
				
			||||||
 | 
					  private Map<String, DynamicExperimentEnrollmentConfiguration> experiments = Collections.emptyMap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(final String experimentName) {
 | 
				
			||||||
 | 
					    return Optional.ofNullable(experiments.get(experimentName));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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<UUID> enrolledUuids = Collections.emptySet();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @JsonProperty
 | 
				
			||||||
 | 
					    @Valid
 | 
				
			||||||
 | 
					    @Min(0)
 | 
				
			||||||
 | 
					    @Max(100)
 | 
				
			||||||
 | 
					    private int enrollmentPercentage = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Set<UUID> getEnrolledUuids() {
 | 
				
			||||||
 | 
					        return enrolledUuids;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public int getEnrollmentPercentage() {
 | 
				
			||||||
 | 
					        return enrollmentPercentage;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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<DynamicExperimentEnrollmentConfiguration> maybeConfiguration = dynamicConfigurationManager.getConfiguration().getExperimentEnrollmentConfiguration(experimentName);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final Set<UUID> 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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -31,7 +31,6 @@ public class DynamicConfigurationManager implements Managed {
 | 
				
			||||||
  private final String          clientId;
 | 
					  private final String          clientId;
 | 
				
			||||||
  private final AmazonAppConfig appConfigClient;
 | 
					  private final AmazonAppConfig appConfigClient;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private final ObjectMapper                            mapper           = new ObjectMapper(new YAMLFactory()).configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
 | 
					 | 
				
			||||||
  private final AtomicReference<DynamicConfiguration>   configuration    = new AtomicReference<>();
 | 
					  private final AtomicReference<DynamicConfiguration>   configuration    = new AtomicReference<>();
 | 
				
			||||||
  private final AtomicBoolean                           running          = new AtomicBoolean(true);
 | 
					  private final AtomicBoolean                           running          = new AtomicBoolean(true);
 | 
				
			||||||
  private final Logger                                  logger           = LoggerFactory.getLogger(DynamicConfigurationManager.class);
 | 
					  private final Logger                                  logger           = LoggerFactory.getLogger(DynamicConfigurationManager.class);
 | 
				
			||||||
| 
						 | 
					@ -40,6 +39,8 @@ public class DynamicConfigurationManager implements Managed {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private boolean initialized = false;
 | 
					  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) {
 | 
					  public DynamicConfigurationManager(String application, String environment, String configurationName) {
 | 
				
			||||||
    this(AmazonAppConfigClient.builder()
 | 
					    this(AmazonAppConfigClient.builder()
 | 
				
			||||||
                              .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout(10000).withRequestTimeout(10000))
 | 
					                              .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout(10000).withRequestTimeout(10000))
 | 
				
			||||||
| 
						 | 
					@ -104,7 +105,7 @@ public class DynamicConfigurationManager implements Managed {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!StringUtils.equals(lastConfigResult.getConfigurationVersion(), previousVersion)) {
 | 
					    if (!StringUtils.equals(lastConfigResult.getConfigurationVersion(), previousVersion)) {
 | 
				
			||||||
      logger.info("Received new config version: {}", lastConfigResult.getConfigurationVersion());
 | 
					      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 {
 | 
					    } else {
 | 
				
			||||||
      // No change since last version
 | 
					      // No change since last version
 | 
				
			||||||
      maybeDynamicConfiguration = Optional.empty();
 | 
					      maybeDynamicConfiguration = Optional.empty();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue