Use the async dynamo client to batch uak updates
This commit is contained in:
		
							parent
							
								
									de68c251f8
								
							
						
					
					
						commit
						5a88ff0811
					
				| 
						 | 
					@ -339,7 +339,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
 | 
				
			||||||
            config.getAppConfig().getConfigurationName(),
 | 
					            config.getAppConfig().getConfigurationName(),
 | 
				
			||||||
            DynamicConfiguration.class);
 | 
					            DynamicConfiguration.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Accounts accounts = new Accounts(dynamicConfigurationManager, dynamoDbClient,
 | 
					    Accounts accounts = new Accounts(dynamicConfigurationManager,
 | 
				
			||||||
 | 
					        dynamoDbClient,
 | 
				
			||||||
 | 
					        dynamoDbAsyncClient,
 | 
				
			||||||
        config.getDynamoDbTables().getAccounts().getTableName(),
 | 
					        config.getDynamoDbTables().getAccounts().getTableName(),
 | 
				
			||||||
        config.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
					        config.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
				
			||||||
        config.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
					        config.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,7 +6,14 @@ public class DynamicUakMigrationConfiguration {
 | 
				
			||||||
  @JsonProperty
 | 
					  @JsonProperty
 | 
				
			||||||
  private boolean enabled = true;
 | 
					  private boolean enabled = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @JsonProperty
 | 
				
			||||||
 | 
					  private int maxOutstandingNormalizes = 25;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public boolean isEnabled() {
 | 
					  public boolean isEnabled() {
 | 
				
			||||||
    return enabled;
 | 
					    return enabled;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public int getMaxOutstandingNormalizes() {
 | 
				
			||||||
 | 
					    return maxOutstandingNormalizes;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,39 +8,47 @@ import static com.codahale.metrics.MetricRegistry.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.fasterxml.jackson.core.JsonProcessingException;
 | 
					import com.fasterxml.jackson.core.JsonProcessingException;
 | 
				
			||||||
import com.google.common.annotations.VisibleForTesting;
 | 
					import com.google.common.annotations.VisibleForTesting;
 | 
				
			||||||
 | 
					import com.google.common.base.Throwables;
 | 
				
			||||||
import com.google.common.collect.Lists;
 | 
					import com.google.common.collect.Lists;
 | 
				
			||||||
import io.micrometer.core.instrument.Counter;
 | 
					import io.micrometer.core.instrument.Counter;
 | 
				
			||||||
import io.micrometer.core.instrument.Metrics;
 | 
					import io.micrometer.core.instrument.Metrics;
 | 
				
			||||||
 | 
					import io.micrometer.core.instrument.Tags;
 | 
				
			||||||
import io.micrometer.core.instrument.Timer;
 | 
					import io.micrometer.core.instrument.Timer;
 | 
				
			||||||
import java.io.IOException;
 | 
					import java.io.IOException;
 | 
				
			||||||
import java.nio.ByteBuffer;
 | 
					import java.nio.ByteBuffer;
 | 
				
			||||||
 | 
					import java.time.Duration;
 | 
				
			||||||
 | 
					import java.time.Instant;
 | 
				
			||||||
import java.util.ArrayList;
 | 
					import java.util.ArrayList;
 | 
				
			||||||
 | 
					import java.util.Arrays;
 | 
				
			||||||
import java.util.HashMap;
 | 
					import java.util.HashMap;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
import java.util.Map;
 | 
					import java.util.Map;
 | 
				
			||||||
import java.util.Objects;
 | 
					import java.util.Objects;
 | 
				
			||||||
import java.util.Optional;
 | 
					import java.util.Optional;
 | 
				
			||||||
import java.util.UUID;
 | 
					import java.util.UUID;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletionException;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletionStage;
 | 
				
			||||||
 | 
					import java.util.concurrent.Semaphore;
 | 
				
			||||||
import java.util.concurrent.TimeUnit;
 | 
					import java.util.concurrent.TimeUnit;
 | 
				
			||||||
 | 
					import java.util.function.Supplier;
 | 
				
			||||||
import java.util.stream.Collectors;
 | 
					import java.util.stream.Collectors;
 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import org.slf4j.LoggerFactory;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
 | 
					import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
 | 
				
			||||||
 | 
					import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicUakMigrationConfiguration;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
 | 
					import org.whispersystems.textsecuregcm.util.AttributeValues;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
 | 
					import org.whispersystems.textsecuregcm.util.SystemMapper;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
 | 
					import org.whispersystems.textsecuregcm.util.UUIDUtil;
 | 
				
			||||||
 | 
					import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 | 
					import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
 | 
					import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.BatchExecuteStatementRequest;
 | 
					 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.BatchExecuteStatementResponse;
 | 
					 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.BatchStatementError;
 | 
					 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.BatchStatementRequest;
 | 
					 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.BatchStatementResponse;
 | 
					 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.CancellationReason;
 | 
					import software.amazon.awssdk.services.dynamodb.model.CancellationReason;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
 | 
					import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.Delete;
 | 
					import software.amazon.awssdk.services.dynamodb.model.Delete;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
 | 
					import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
 | 
					import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
 | 
				
			||||||
 | 
					import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughputExceededException;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.Put;
 | 
					import software.amazon.awssdk.services.dynamodb.model.Put;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
 | 
					import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
 | 
					import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
 | 
				
			||||||
| 
						 | 
					@ -50,7 +58,7 @@ import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledExcepti
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;
 | 
					import software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.Update;
 | 
					import software.amazon.awssdk.services.dynamodb.model.Update;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
 | 
					import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;
 | 
					import software.amazon.awssdk.utils.CompletableFutureUtils;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class Accounts extends AbstractDynamoDbStore {
 | 
					public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -71,8 +79,9 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
  // unidentified access key; byte[] or null
 | 
					  // unidentified access key; byte[] or null
 | 
				
			||||||
  static final String ATTR_UAK = "UAK";
 | 
					  static final String ATTR_UAK = "UAK";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
 | 
					  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
 | 
				
			||||||
  private final DynamoDbClient client;
 | 
					  private final DynamoDbClient client;
 | 
				
			||||||
 | 
					  private final DynamoDbAsyncClient asyncClient;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private final String phoneNumberConstraintTableName;
 | 
					  private final String phoneNumberConstraintTableName;
 | 
				
			||||||
  private final String phoneNumberIdentifierConstraintTableName;
 | 
					  private final String phoneNumberIdentifierConstraintTableName;
 | 
				
			||||||
| 
						 | 
					@ -96,18 +105,21 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
  private static final Timer NORMALIZE_ITEM_TIMER = Metrics.timer(name(Accounts.class, "normalizeItem"));
 | 
					  private static final Timer NORMALIZE_ITEM_TIMER = Metrics.timer(name(Accounts.class, "normalizeItem"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private static final Counter UAK_NORMALIZE_SUCCESS_COUNT = Metrics.counter(name(Accounts.class, "normalizeUakSuccess"));
 | 
					  private static final Counter UAK_NORMALIZE_SUCCESS_COUNT = Metrics.counter(name(Accounts.class, "normalizeUakSuccess"));
 | 
				
			||||||
  private static final Counter UAK_NORMALIZE_ERROR_COUNT = Metrics.counter(name(Accounts.class, "normalizeUakError"));
 | 
					  private static final String UAK_NORMALIZE_ERROR_NAME = name(Accounts.class, "normalizeUakError");
 | 
				
			||||||
 | 
					  private static final String UAK_NORMALIZE_FAILURE_REASON_TAG_NAME = "reason";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private static final Logger log = LoggerFactory.getLogger(Accounts.class);
 | 
					  private static final Logger log = LoggerFactory.getLogger(Accounts.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
 | 
					  public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
 | 
				
			||||||
      DynamoDbClient client, String accountsTableName, String phoneNumberConstraintTableName,
 | 
					      DynamoDbClient client, DynamoDbAsyncClient asyncClient,
 | 
				
			||||||
 | 
					      String accountsTableName, String phoneNumberConstraintTableName,
 | 
				
			||||||
      String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
 | 
					      String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
 | 
				
			||||||
      final int scanPageSize) {
 | 
					      final int scanPageSize) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    super(client);
 | 
					    super(client);
 | 
				
			||||||
    this.dynamicConfigurationManager = dynamicConfigurationManager;
 | 
					    this.dynamicConfigurationManager = dynamicConfigurationManager;
 | 
				
			||||||
    this.client = client;
 | 
					    this.client = client;
 | 
				
			||||||
 | 
					    this.asyncClient = asyncClient;
 | 
				
			||||||
    this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
 | 
					    this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
 | 
				
			||||||
    this.phoneNumberIdentifierConstraintTableName = phoneNumberIdentifierConstraintTableName;
 | 
					    this.phoneNumberIdentifierConstraintTableName = phoneNumberIdentifierConstraintTableName;
 | 
				
			||||||
    this.accountsTableName = accountsTableName;
 | 
					    this.accountsTableName = accountsTableName;
 | 
				
			||||||
| 
						 | 
					@ -469,10 +481,19 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public void update(Account account) throws ContestedOptimisticLockException {
 | 
					  /**
 | 
				
			||||||
    UPDATE_TIMER.record(() -> {
 | 
					   * Extract the cause from a CompletionException
 | 
				
			||||||
      final UpdateItemRequest updateItemRequest;
 | 
					   */
 | 
				
			||||||
 | 
					  private static Throwable unwrap(Throwable throwable) {
 | 
				
			||||||
 | 
					    while (throwable instanceof CompletionException e && throwable.getCause() != null) {
 | 
				
			||||||
 | 
					      throwable = e.getCause();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return throwable;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public CompletionStage<Void> updateAsync(Account account) {
 | 
				
			||||||
 | 
					    return record(UPDATE_TIMER, () -> {
 | 
				
			||||||
 | 
					      final UpdateItemRequest updateItemRequest;
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        // username, e164, and pni cannot be modified through this method
 | 
					        // username, e164, and pni cannot be modified through this method
 | 
				
			||||||
        Map<String, String> attrNames = new HashMap<>(Map.of(
 | 
					        Map<String, String> attrNames = new HashMap<>(Map.of(
 | 
				
			||||||
| 
						 | 
					@ -508,21 +529,41 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
        throw new IllegalArgumentException(e);
 | 
					        throw new IllegalArgumentException(e);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      try {
 | 
					      return asyncClient.updateItem(updateItemRequest)
 | 
				
			||||||
        final UpdateItemResponse response = client.updateItem(updateItemRequest);
 | 
					          .thenApply(response -> {
 | 
				
			||||||
        account.setVersion(AttributeValues.getInt(response.attributes(), "V", account.getVersion() + 1));
 | 
					            account.setVersion(AttributeValues.getInt(response.attributes(), "V", account.getVersion() + 1));
 | 
				
			||||||
      } catch (final TransactionConflictException e) {
 | 
					            return (Void) null;
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
        throw new ContestedOptimisticLockException();
 | 
					          .exceptionally(throwable -> {
 | 
				
			||||||
 | 
					            final Throwable unwrapped = unwrap(throwable);
 | 
				
			||||||
      } catch (final ConditionalCheckFailedException e) {
 | 
					            if (unwrapped instanceof TransactionConflictException) {
 | 
				
			||||||
        // the exception doesn't give details about which condition failed,
 | 
					              throw new ContestedOptimisticLockException();
 | 
				
			||||||
        // but we can infer it was an optimistic locking failure if the UUID is known
 | 
					            } else if (unwrapped instanceof ConditionalCheckFailedException e) {
 | 
				
			||||||
        throw getByAccountIdentifier(account.getUuid()).isPresent() ? new ContestedOptimisticLockException() : e;
 | 
					              // the exception doesn't give details about which condition failed,
 | 
				
			||||||
      }
 | 
					              // but we can infer it was an optimistic locking failure if the UUID is known
 | 
				
			||||||
 | 
					              throw getByAccountIdentifier(account.getUuid()).isPresent() ? new ContestedOptimisticLockException() : e;
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              // rethrow
 | 
				
			||||||
 | 
					              throw CompletableFutureUtils.errorAsCompletionException(throwable);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public void update(Account account) throws ContestedOptimisticLockException {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      this.updateAsync(account).toCompletableFuture().join();
 | 
				
			||||||
 | 
					    } catch (CompletionException e) {
 | 
				
			||||||
 | 
					      // unwrap CompletionExceptions, throw as long is it's unchecked
 | 
				
			||||||
 | 
					      Throwables.throwIfUnchecked(unwrap(e));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // if we otherwise somehow got a wrapped checked exception,
 | 
				
			||||||
 | 
					      // rethrow the checked exception wrapped by the original CompletionException
 | 
				
			||||||
 | 
					      log.error("Unexpected checked exception thrown from dynamo update", e);
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public Optional<Account> getByE164(String number) {
 | 
					  public Optional<Account> getByE164(String number) {
 | 
				
			||||||
    return GET_BY_NUMBER_TIMER.record(() -> {
 | 
					    return GET_BY_NUMBER_TIMER.record(() -> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -641,6 +682,11 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
    return scanForChunk(scanRequestBuilder, maxCount, GET_ALL_FROM_START_TIMER);
 | 
					    return scanForChunk(scanRequestBuilder, maxCount, GET_ALL_FROM_START_TIMER);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private static <T> CompletionStage<T> record(final Timer timer, Supplier<CompletionStage<T>> toRecord)  {
 | 
				
			||||||
 | 
					    final Instant start = Instant.now();
 | 
				
			||||||
 | 
					    return toRecord.get().whenComplete((ignoreT, ignoreE) -> timer.record(Duration.between(start, Instant.now())));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private List<Account> normalizeIfRequired(final List<Map<String, AttributeValue>> items) {
 | 
					  private List<Account> normalizeIfRequired(final List<Map<String, AttributeValue>> items) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // The UAK top-level attribute may not exist on older records,
 | 
					    // The UAK top-level attribute may not exist on older records,
 | 
				
			||||||
| 
						 | 
					@ -653,52 +699,62 @@ public class Accounts extends AbstractDynamoDbStore {
 | 
				
			||||||
      final Account account = fromItem(item);
 | 
					      final Account account = fromItem(item);
 | 
				
			||||||
      allAccounts.add(account);
 | 
					      allAccounts.add(account);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!item.containsKey(ATTR_UAK) && account.getUnidentifiedAccessKey().isPresent()) {
 | 
					      boolean hasAttrUak = item.containsKey(ATTR_UAK);
 | 
				
			||||||
 | 
					      if (!hasAttrUak && account.getUnidentifiedAccessKey().isPresent()) {
 | 
				
			||||||
        // the top level uak attribute doesn't exist, but there's a uak in the account
 | 
					        // the top level uak attribute doesn't exist, but there's a uak in the account
 | 
				
			||||||
        accountsToNormalize.add(account);
 | 
					        accountsToNormalize.add(account);
 | 
				
			||||||
 | 
					      } else if (hasAttrUak && account.getUnidentifiedAccessKey().isPresent()) {
 | 
				
			||||||
 | 
					        final AttributeValue attr = item.get(ATTR_UAK);
 | 
				
			||||||
 | 
					        final byte[] nestedUak = account.getUnidentifiedAccessKey().get();
 | 
				
			||||||
 | 
					        if (!Arrays.equals(attr.b().asByteArray(), nestedUak)) {
 | 
				
			||||||
 | 
					          log.warn("Discovered mismatch between attribute UAK data UAK, normalizing");
 | 
				
			||||||
 | 
					          accountsToNormalize.add(account);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!this.dynamicConfigurationManager.getConfiguration().getUakMigrationConfiguration().isEnabled()) {
 | 
					    final DynamicUakMigrationConfiguration currentConfig = this.dynamicConfigurationManager.getConfiguration().getUakMigrationConfiguration();
 | 
				
			||||||
 | 
					    if (!currentConfig.isEnabled()) {
 | 
				
			||||||
      log.debug("Account normalization is disabled, skipping normalization for {} accounts", accountsToNormalize.size());
 | 
					      log.debug("Account normalization is disabled, skipping normalization for {} accounts", accountsToNormalize.size());
 | 
				
			||||||
      return allAccounts;
 | 
					      return allAccounts;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final int BATCH_SIZE = 25; // dynamodb max batch size
 | 
					    for (List<Account> accounts : Lists.partition(accountsToNormalize, currentConfig.getMaxOutstandingNormalizes())) {
 | 
				
			||||||
    final String updateUakStatement = String.format("UPDATE %s SET %s = ? WHERE %s = ?", accountsTableName, ATTR_UAK, KEY_ACCOUNT_UUID);
 | 
					      try {
 | 
				
			||||||
    for (List<Account> toNormalize : Lists.partition(accountsToNormalize, BATCH_SIZE)) {
 | 
					        final CompletableFuture<?>[] accountFutures = accounts.stream()
 | 
				
			||||||
      NORMALIZE_ITEM_TIMER.record(() -> {
 | 
					            .map(account -> record(NORMALIZE_ITEM_TIMER,
 | 
				
			||||||
        try {
 | 
					                () -> this.updateAsync(account).whenComplete((result, throwable) -> {
 | 
				
			||||||
          final List<BatchStatementRequest> updateStatements = toNormalize.stream()
 | 
					                  if (throwable == null) {
 | 
				
			||||||
              .map(account -> BatchStatementRequest.builder()
 | 
					                    UAK_NORMALIZE_SUCCESS_COUNT.increment();
 | 
				
			||||||
                  .statement(updateUakStatement)
 | 
					                    return;
 | 
				
			||||||
                  .parameters(
 | 
					                  }
 | 
				
			||||||
                      AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get()),
 | 
					 | 
				
			||||||
                      AttributeValues.fromUUID(account.getUuid()))
 | 
					 | 
				
			||||||
                  .build())
 | 
					 | 
				
			||||||
              .toList();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          final BatchExecuteStatementResponse result = client.batchExecuteStatement(BatchExecuteStatementRequest
 | 
					                  throwable = unwrap(throwable);
 | 
				
			||||||
              .builder()
 | 
					                  if (throwable instanceof ContestedOptimisticLockException) {
 | 
				
			||||||
              .statements(updateStatements)
 | 
					                    // Could succeed on retry, but just backoff since this is a housekeeping operation
 | 
				
			||||||
              .build());
 | 
					                    Metrics.counter(UAK_NORMALIZE_ERROR_NAME,
 | 
				
			||||||
 | 
					                        Tags.of(UAK_NORMALIZE_FAILURE_REASON_TAG_NAME, "ContestedOptimisticLock")).increment();
 | 
				
			||||||
 | 
					                  } else if (throwable instanceof ProvisionedThroughputExceededException) {
 | 
				
			||||||
 | 
					                    Metrics.counter(UAK_NORMALIZE_ERROR_NAME,
 | 
				
			||||||
 | 
					                            Tags.of(UAK_NORMALIZE_FAILURE_REASON_TAG_NAME, "ProvisionedThroughPutExceeded"))
 | 
				
			||||||
 | 
					                        .increment();
 | 
				
			||||||
 | 
					                  } else {
 | 
				
			||||||
 | 
					                    log.warn("Failed to normalize account, skipping", throwable);
 | 
				
			||||||
 | 
					                    Metrics.counter(UAK_NORMALIZE_ERROR_NAME,
 | 
				
			||||||
 | 
					                            Tags.of(UAK_NORMALIZE_FAILURE_REASON_TAG_NAME, "unknown"))
 | 
				
			||||||
 | 
					                        .increment();
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                })).toCompletableFuture()).toArray(CompletableFuture[]::new);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          final Map<String, Long> errors = result.responses().stream()
 | 
					        // wait for a futures in batch to complete
 | 
				
			||||||
              .map(BatchStatementResponse::error)
 | 
					        CompletableFuture
 | 
				
			||||||
              .filter(e -> e != null)
 | 
					            .allOf(accountFutures)
 | 
				
			||||||
              .collect(Collectors.groupingBy(BatchStatementError::codeAsString, Collectors.counting()));
 | 
					            // exceptions handled in individual futures
 | 
				
			||||||
 | 
					            .exceptionally(e -> null)
 | 
				
			||||||
          final long errorCount = errors.values().stream().mapToLong(Long::longValue).sum();
 | 
					            .join();
 | 
				
			||||||
          UAK_NORMALIZE_SUCCESS_COUNT.increment(toNormalize.size() - errorCount);
 | 
					      } catch (Exception e) {
 | 
				
			||||||
          UAK_NORMALIZE_ERROR_COUNT.increment(errorCount);
 | 
					        log.warn("Failed to update batch of {} accounts, skipping", accounts.size(), e);
 | 
				
			||||||
          if (!errors.isEmpty()) {
 | 
					      }
 | 
				
			||||||
            log.warn("Failed to normalize account uaks in batch of {}, error codes: {}", toNormalize.size(), errors);
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } catch (final Exception e) {
 | 
					 | 
				
			||||||
          UAK_NORMALIZE_ERROR_COUNT.increment(toNormalize.size());
 | 
					 | 
				
			||||||
          log.warn("Failed to normalize accounts in a batch of {}", toNormalize.size(), e);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return allAccounts;
 | 
					    return allAccounts;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -135,7 +135,9 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
 | 
				
			||||||
    VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
					    VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
				
			||||||
        configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
					        configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Accounts accounts = new Accounts(dynamicConfigurationManager, dynamoDbClient,
 | 
					    Accounts accounts = new Accounts(dynamicConfigurationManager,
 | 
				
			||||||
 | 
					        dynamoDbClient,
 | 
				
			||||||
 | 
					        dynamoDbAsyncClient,
 | 
				
			||||||
        configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
					        configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
				
			||||||
        configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
					        configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
				
			||||||
        configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
					        configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -138,7 +138,9 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
 | 
				
			||||||
      VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
					      VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
				
			||||||
          configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
					          configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      Accounts accounts = new Accounts(dynamicConfigurationManager, dynamoDbClient,
 | 
					      Accounts accounts = new Accounts(dynamicConfigurationManager,
 | 
				
			||||||
 | 
					          dynamoDbClient,
 | 
				
			||||||
 | 
					          dynamoDbAsyncClient,
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -141,7 +141,9 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
 | 
				
			||||||
      VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
					      VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient,
 | 
				
			||||||
          configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
					          configuration.getDynamoDbTables().getPendingAccounts().getTableName());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      Accounts accounts = new Accounts(dynamicConfigurationManager, dynamoDbClient,
 | 
					      Accounts accounts = new Accounts(dynamicConfigurationManager,
 | 
				
			||||||
 | 
					          dynamoDbClient,
 | 
				
			||||||
 | 
					          dynamoDbAsyncClient,
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getTableName(),
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
 | 
				
			||||||
          configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
					          configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -154,6 +154,7 @@ class AccountsManagerChangeNumberIntegrationTest {
 | 
				
			||||||
      final Accounts accounts = new Accounts(
 | 
					      final Accounts accounts = new Accounts(
 | 
				
			||||||
          dynamicConfigurationManager,
 | 
					          dynamicConfigurationManager,
 | 
				
			||||||
          ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(),
 | 
					          ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(),
 | 
				
			||||||
 | 
					          ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbAsyncClient(),
 | 
				
			||||||
          ACCOUNTS_DYNAMO_EXTENSION.getTableName(),
 | 
					          ACCOUNTS_DYNAMO_EXTENSION.getTableName(),
 | 
				
			||||||
          NUMBERS_TABLE_NAME,
 | 
					          NUMBERS_TABLE_NAME,
 | 
				
			||||||
          PNI_ASSIGNMENT_TABLE_NAME,
 | 
					          PNI_ASSIGNMENT_TABLE_NAME,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -126,6 +126,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
 | 
				
			||||||
    accounts = new Accounts(
 | 
					    accounts = new Accounts(
 | 
				
			||||||
        dynamicConfigurationManager,
 | 
					        dynamicConfigurationManager,
 | 
				
			||||||
        dynamoDbExtension.getDynamoDbClient(),
 | 
					        dynamoDbExtension.getDynamoDbClient(),
 | 
				
			||||||
 | 
					        dynamoDbExtension.getDynamoDbAsyncClient(),
 | 
				
			||||||
        dynamoDbExtension.getTableName(),
 | 
					        dynamoDbExtension.getTableName(),
 | 
				
			||||||
        NUMBERS_TABLE_NAME,
 | 
					        NUMBERS_TABLE_NAME,
 | 
				
			||||||
        PNI_TABLE_NAME,
 | 
					        PNI_TABLE_NAME,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,6 +28,8 @@ import java.util.Optional;
 | 
				
			||||||
import java.util.Random;
 | 
					import java.util.Random;
 | 
				
			||||||
import java.util.Set;
 | 
					import java.util.Set;
 | 
				
			||||||
import java.util.UUID;
 | 
					import java.util.UUID;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletionException;
 | 
				
			||||||
import java.util.stream.Collectors;
 | 
					import java.util.stream.Collectors;
 | 
				
			||||||
import java.util.stream.IntStream;
 | 
					import java.util.stream.IntStream;
 | 
				
			||||||
import org.jdbi.v3.core.transaction.TransactionException;
 | 
					import org.jdbi.v3.core.transaction.TransactionException;
 | 
				
			||||||
| 
						 | 
					@ -42,6 +44,7 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
 | 
				
			||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
 | 
					import org.whispersystems.textsecuregcm.entities.SignedPreKey;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
 | 
					import org.whispersystems.textsecuregcm.util.AttributeValues;
 | 
				
			||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
 | 
					import org.whispersystems.textsecuregcm.util.SystemMapper;
 | 
				
			||||||
 | 
					import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 | 
					import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
 | 
					import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
 | 
				
			||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
 | 
					import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
 | 
				
			||||||
| 
						 | 
					@ -139,6 +142,7 @@ class AccountsTest {
 | 
				
			||||||
    this.accounts = new Accounts(
 | 
					    this.accounts = new Accounts(
 | 
				
			||||||
        mockDynamicConfigManager,
 | 
					        mockDynamicConfigManager,
 | 
				
			||||||
        dynamoDbExtension.getDynamoDbClient(),
 | 
					        dynamoDbExtension.getDynamoDbClient(),
 | 
				
			||||||
 | 
					        dynamoDbExtension.getDynamoDbAsyncClient(),
 | 
				
			||||||
        dynamoDbExtension.getTableName(),
 | 
					        dynamoDbExtension.getTableName(),
 | 
				
			||||||
        NUMBER_CONSTRAINT_TABLE_NAME,
 | 
					        NUMBER_CONSTRAINT_TABLE_NAME,
 | 
				
			||||||
        PNI_CONSTRAINT_TABLE_NAME,
 | 
					        PNI_CONSTRAINT_TABLE_NAME,
 | 
				
			||||||
| 
						 | 
					@ -377,15 +381,20 @@ class AccountsTest {
 | 
				
			||||||
    verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), account, true);
 | 
					    verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), account, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Test
 | 
					  @ParameterizedTest
 | 
				
			||||||
  void testUpdateWithMockTransactionConflictException() {
 | 
					  @ValueSource(booleans = {true, false})
 | 
				
			||||||
 | 
					  void testUpdateWithMockTransactionConflictException(boolean wrapException) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);
 | 
					    final DynamoDbAsyncClient dynamoDbAsyncClient = mock(DynamoDbAsyncClient.class);
 | 
				
			||||||
    accounts = new Accounts(mockDynamicConfigManager, dynamoDbClient,
 | 
					    accounts = new Accounts(mockDynamicConfigManager, mock(DynamoDbClient.class),
 | 
				
			||||||
        dynamoDbExtension.getTableName(), NUMBER_CONSTRAINT_TABLE_NAME, PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
 | 
					        dynamoDbAsyncClient, dynamoDbExtension.getTableName(),
 | 
				
			||||||
 | 
					        NUMBER_CONSTRAINT_TABLE_NAME, PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    when(dynamoDbClient.updateItem(any(UpdateItemRequest.class)))
 | 
					    Exception e = TransactionConflictException.builder().build();
 | 
				
			||||||
        .thenThrow(TransactionConflictException.class);
 | 
					    e = wrapException ? new CompletionException(e) : e;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    when(dynamoDbAsyncClient.updateItem(any(UpdateItemRequest.class)))
 | 
				
			||||||
 | 
					        .thenReturn(CompletableFuture.failedFuture(e));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
 | 
					    Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -512,14 +521,15 @@ class AccountsTest {
 | 
				
			||||||
    configuration.setFailureRateThreshold(50);
 | 
					    configuration.setFailureRateThreshold(50);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final DynamoDbClient client = mock(DynamoDbClient.class);
 | 
					    final DynamoDbClient client = mock(DynamoDbClient.class);
 | 
				
			||||||
 | 
					    final DynamoDbAsyncClient asyncClient = mock(DynamoDbAsyncClient.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    when(client.transactWriteItems(any(TransactWriteItemsRequest.class)))
 | 
					    when(client.transactWriteItems(any(TransactWriteItemsRequest.class)))
 | 
				
			||||||
        .thenThrow(RuntimeException.class);
 | 
					        .thenThrow(RuntimeException.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    when(client.updateItem(any(UpdateItemRequest.class)))
 | 
					    when(asyncClient.updateItem(any(UpdateItemRequest.class)))
 | 
				
			||||||
        .thenThrow(RuntimeException.class);
 | 
					        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Accounts accounts = new Accounts(mockDynamicConfigManager, client, ACCOUNTS_TABLE_NAME, NUMBER_CONSTRAINT_TABLE_NAME,
 | 
					    Accounts accounts = new Accounts(mockDynamicConfigManager, client, asyncClient, ACCOUNTS_TABLE_NAME, NUMBER_CONSTRAINT_TABLE_NAME,
 | 
				
			||||||
        PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
 | 
					        PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
 | 
				
			||||||
    Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
 | 
					    Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -816,6 +826,40 @@ class AccountsTest {
 | 
				
			||||||
    assertThat(item).doesNotContainKey(Accounts.ATTR_UAK);
 | 
					    assertThat(item).doesNotContainKey(Accounts.ATTR_UAK);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Test
 | 
				
			||||||
 | 
					  void testUakMismatch() {
 | 
				
			||||||
 | 
					    // If there's a UAK mismatch, we should correct it
 | 
				
			||||||
 | 
					    final UUID accountIdentifier = UUID.randomUUID();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final Account account = generateAccount("+18005551234", accountIdentifier, UUID.randomUUID());
 | 
				
			||||||
 | 
					    accounts.create(account);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // set the uak to garbage in the attributes
 | 
				
			||||||
 | 
					    dynamoDbExtension.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
 | 
				
			||||||
 | 
					        .tableName(ACCOUNTS_TABLE_NAME)
 | 
				
			||||||
 | 
					        .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountIdentifier)))
 | 
				
			||||||
 | 
					        .expressionAttributeNames(Map.of("#uak", Accounts.ATTR_UAK))
 | 
				
			||||||
 | 
					        .expressionAttributeValues(Map.of(":uak", AttributeValues.fromByteArray("bad-uak".getBytes())))
 | 
				
			||||||
 | 
					        .updateExpression("SET #uak = :uak").build());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // crawling should return 1 account and fix the uak mismatch
 | 
				
			||||||
 | 
					    final AccountCrawlChunk allFromStart = accounts.getAllFromStart(1);
 | 
				
			||||||
 | 
					    assertThat(allFromStart.getAccounts()).hasSize(1);
 | 
				
			||||||
 | 
					    assertThat(allFromStart.getAccounts().get(0).getUuid()).isEqualTo(accountIdentifier);
 | 
				
			||||||
 | 
					    assertThat(allFromStart.getAccounts().get(0).getUnidentifiedAccessKey().get()).isEqualTo(account.getUnidentifiedAccessKey().get());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // the top level uak should be the original
 | 
				
			||||||
 | 
					    final Map<String, AttributeValue> item = dynamoDbExtension.getDynamoDbClient()
 | 
				
			||||||
 | 
					        .getItem(GetItemRequest.builder()
 | 
				
			||||||
 | 
					            .tableName(ACCOUNTS_TABLE_NAME)
 | 
				
			||||||
 | 
					            .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountIdentifier)))
 | 
				
			||||||
 | 
					            .consistentRead(true)
 | 
				
			||||||
 | 
					            .build()).item();
 | 
				
			||||||
 | 
					    assertThat(item).containsEntry(
 | 
				
			||||||
 | 
					        Accounts.ATTR_UAK,
 | 
				
			||||||
 | 
					        AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get()));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ParameterizedTest
 | 
					  @ParameterizedTest
 | 
				
			||||||
  @ValueSource(booleans = {true, false})
 | 
					  @ValueSource(booleans = {true, false})
 | 
				
			||||||
  void testAddMissingUakAttribute(boolean normalizeDisabled) throws JsonProcessingException {
 | 
					  void testAddMissingUakAttribute(boolean normalizeDisabled) throws JsonProcessingException {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue