Map TransactionConflict to ContestedOptimisticLockException in username flows

This commit is contained in:
Katherine 2024-02-23 14:06:03 -08:00 committed by GitHub
parent 69330f47fd
commit 12c6af23ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 165 additions and 11 deletions

View File

@ -455,7 +455,8 @@ public class Accounts extends AbstractDynamoDbStore {
/**
* Reserve a username hash under the account UUID
* @return a future that completes once the username hash has been reserved; may fail with an
* {@link ContestedOptimisticLockException} if the account has been updated or
* {@link ContestedOptimisticLockException} if the account has been updated or there are concurrent updates to the
* account or constraint records, and with an
* {@link UsernameHashNotAvailableException} if the username was taken by someone else
*/
public CompletableFuture<Void> reserveUsernameHash(
@ -523,11 +524,12 @@ public class Accounts extends AbstractDynamoDbStore {
.exceptionally(throwable -> {
if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException e) {
// If the constraint table update failed the condition check, the username's taken and we should stop
// trying. However if it was only in the accounts table that the condition check update failed, it's an
// optimistic locking failure (the account was concurrently updated) and we should try again.
// trying. However, if the accounts table fails the conditional check or
// either table was concurrently updated, it's an optimistic locking failure and we should try again.
if (conditionalCheckFailed(e.cancellationReasons().get(0))) {
throw ExceptionUtils.wrap(new UsernameHashNotAvailableException());
} else if (conditionalCheckFailed(e.cancellationReasons().get(1))) {
} else if (conditionalCheckFailed(e.cancellationReasons().get(1)) ||
e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {
throw new ContestedOptimisticLockException();
}
}
@ -551,8 +553,10 @@ public class Accounts extends AbstractDynamoDbStore {
*
* @param account to update
* @param usernameHash believed to be available
* @param encryptedUsername the encrypted form of the previously reserved username; used for the username link
* @return a future that completes once the username hash has been confirmed; may fail with an
* {@link ContestedOptimisticLockException} if the account has been updated or
* {@link ContestedOptimisticLockException} if the account has been updated or there are concurrent updates to the
* account or constraint records, and with an
* {@link UsernameHashNotAvailableException} if the username was taken by someone else
*/
public CompletableFuture<Void> confirmUsernameHash(final Account account, final byte[] usernameHash, @Nullable final byte[] encryptedUsername) {
@ -585,13 +589,14 @@ public class Accounts extends AbstractDynamoDbStore {
.exceptionally(throwable -> {
if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException e) {
// If the constraint table update failed the condition check, the username's taken and we should stop
// trying. However if it was only in the accounts table that the condition check update failed, it's an
// optimistic locking failure (the account was concurrently updated) and we should try again.
// trying. However, if the accounts table fails the conditional check or
// either table was concurrently updated, it's an optimistic locking failure and we should try again.
// NOTE: the fixed indices here must be kept in sync with the creation of the TransactWriteItems in
// buildConfirmUsernameHashRequest!
if (conditionalCheckFailed(e.cancellationReasons().get(0))) {
throw ExceptionUtils.wrap(new UsernameHashNotAvailableException());
} else if (conditionalCheckFailed(e.cancellationReasons().get(1))) {
} else if (conditionalCheckFailed(e.cancellationReasons().get(1)) ||
e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {
throw new ContestedOptimisticLockException();
}
}
@ -690,6 +695,14 @@ public class Accounts extends AbstractDynamoDbStore {
.build();
}
/**
* Clear the username hash and link from the given account
*
* @param account to update
* @return a future that completes once the username data has been cleared;
* it can fail with a {@link ContestedOptimisticLockException} if there are concurrent updates
* to the account or username constraint records.
*/
public CompletableFuture<Void> clearUsernameHash(final Account account) {
return account.getUsernameHash().map(usernameHash -> {
final Timer.Sample sample = Timer.start();
@ -714,8 +727,9 @@ public class Accounts extends AbstractDynamoDbStore {
account.setVersion(account.getVersion() + 1);
})
.exceptionally(throwable -> {
if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException transactionCanceledException) {
if (conditionalCheckFailed(transactionCanceledException.cancellationReasons().get(0))) {
if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException e) {
if (conditionalCheckFailed(e.cancellationReasons().get(0)) ||
e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {
throw new ContestedOptimisticLockException();
}
}
@ -1371,4 +1385,8 @@ public class Accounts extends AbstractDynamoDbStore {
private static boolean conditionalCheckFailed(final CancellationReason reason) {
return CONDITIONAL_CHECK_FAILED.equals(reason.code());
}
private static boolean isTransactionConflict(final CancellationReason reason) {
return TRANSACTION_CONFLICT.equals(reason.code());
}
}

View File

@ -73,6 +73,7 @@ import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsResponse;
import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
import software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
@ -960,7 +961,7 @@ class AccountsTest {
}
@Test
void testUsernameHashConflict() {
void testUsernameHashNotAvailable() {
final Account firstAccount = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
final Account secondAccount = generateAccount("+18005559876", UUID.randomUUID(), UUID.randomUUID());
@ -994,6 +995,92 @@ class AccountsTest {
assertThat(secondAccount.getUsernameHash()).isEmpty();
}
@ParameterizedTest
@MethodSource
void testReserveUsernameHashTransactionConflict(final Optional<String> constraintCancellationString,
final Optional<String> accountsCancellationString,
final Class<Exception> expectedException) {
final DynamoDbAsyncClient dbAsyncClient = mock(DynamoDbAsyncClient.class);
accounts = new Accounts(mock(DynamoDbClient.class),
dbAsyncClient,
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
Tables.USERNAMES.tableName(),
Tables.DELETED_ACCOUNTS.tableName());
final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID());
createAccount(account);
final CancellationReason constraintCancellationReason = constraintCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
final CancellationReason accountsCancellationReason = accountsCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
when(dbAsyncClient.transactWriteItems(any(TransactWriteItemsRequest.class)))
.thenReturn(CompletableFuture.failedFuture(TransactionCanceledException.builder()
.cancellationReasons(constraintCancellationReason, accountsCancellationReason)
.build()));
CompletableFutureTestUtil.assertFailsWithCause(expectedException,
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)));
}
private static Stream<Arguments> testReserveUsernameHashTransactionConflict() {
return Stream.of(
Arguments.of(Optional.of("TransactionConflict"), Optional.empty(), ContestedOptimisticLockException.class),
Arguments.of(Optional.empty(), Optional.of("TransactionConflict"), ContestedOptimisticLockException.class),
Arguments.of(Optional.of("ConditionalCheckFailed"), Optional.of("TransactionConflict"), UsernameHashNotAvailableException.class)
);
}
@ParameterizedTest
@MethodSource
void testConfirmUsernameHashTransactionConflict(final Optional<String> constraintCancellationString,
final Optional<String> accountsCancellationString,
final Class<Exception> expectedException) {
final DynamoDbAsyncClient dbAsyncClient = mock(DynamoDbAsyncClient.class);
accounts = new Accounts(mock(DynamoDbClient.class),
dbAsyncClient,
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
Tables.USERNAMES.tableName(),
Tables.DELETED_ACCOUNTS.tableName());
final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID());
createAccount(account);
final CancellationReason constraintCancellationReason = constraintCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
final CancellationReason accountsCancellationReason = accountsCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
when(dbAsyncClient.transactWriteItems(any(TransactWriteItemsRequest.class)))
.thenReturn(CompletableFuture.failedFuture(TransactionCanceledException.builder()
.cancellationReasons(constraintCancellationReason,
accountsCancellationReason,
CancellationReason.builder().build())
.build()));
CompletableFutureTestUtil.assertFailsWithCause(expectedException,
accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));
}
private static Stream<Arguments> testConfirmUsernameHashTransactionConflict() {
return Stream.of(
Arguments.of(Optional.of("TransactionConflict"), Optional.empty(), ContestedOptimisticLockException.class),
Arguments.of(Optional.empty(), Optional.of("TransactionConflict"), ContestedOptimisticLockException.class),
Arguments.of(Optional.of("ConditionalCheckFailed"), Optional.of("TransactionConflict"), UsernameHashNotAvailableException.class)
);
}
@Test
void testConfirmUsernameHashVersionMismatch() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
@ -1051,6 +1138,55 @@ class AccountsTest {
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
}
@ParameterizedTest
@MethodSource
void testClearUsernameTransactionConflict(final Optional<String> constraintCancellationString,
final Optional<String> accountsCancellationString) {
final DynamoDbAsyncClient dbAsyncClient = mock(DynamoDbAsyncClient.class);
accounts = new Accounts(mock(DynamoDbClient.class),
dbAsyncClient,
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
Tables.USERNAMES.tableName(),
Tables.DELETED_ACCOUNTS.tableName());
final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID());
createAccount(account);
when(dbAsyncClient.transactWriteItems(any(TransactWriteItemsRequest.class)))
.thenReturn(CompletableFuture.completedFuture(mock(TransactWriteItemsResponse.class)));
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)).join();
accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1).join();
final CancellationReason constraintCancellationReason = constraintCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
final CancellationReason accountsCancellationReason = accountsCancellationString.map(
reason -> CancellationReason.builder().code(reason).build()
).orElse(CancellationReason.builder().build());
when(dbAsyncClient.transactWriteItems(any(TransactWriteItemsRequest.class)))
.thenReturn(CompletableFuture.failedFuture(TransactionCanceledException.builder()
.cancellationReasons(accountsCancellationReason, constraintCancellationReason)
.build()));
CompletableFutureTestUtil.assertFailsWithCause(ContestedOptimisticLockException.class,
accounts.clearUsernameHash(account));
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
}
private static Stream<Arguments> testClearUsernameTransactionConflict() {
return Stream.of(
Arguments.of(Optional.empty(), Optional.of("TransactionConflict"), ContestedOptimisticLockException.class),
Arguments.of(Optional.of("TransactionConflict"), Optional.empty(), ContestedOptimisticLockException.class)
);
}
@Test
void testReservedUsernameHash() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());