From 1021b0056b94a7bfab03816c68417f20f99aa992 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 29 Sep 2025 09:57:04 +0300 Subject: [PATCH 1/5] [POC] Optimistic Locking for Delete Operations --- .../AsyncCrudWithResponseIntegrationTest.java | 226 ++++++++++++++++- .../CrudWithResponseIntegrationTest.java | 231 +++++++++++++++++- .../DynamoDbEnhancedIntegrationTestBase.java | 33 +++ .../dynamodb/model/VersionedRecord.java | 120 +++++++++ .../enhanced/dynamodb/DynamoDbAsyncTable.java | 4 + .../enhanced/dynamodb/DynamoDbTable.java | 4 + .../client/DefaultDynamoDbAsyncTable.java | 12 +- .../internal/client/DefaultDynamoDbTable.java | 12 +- .../model/DeleteItemEnhancedRequest.java | 8 + .../model/OptimisticLockingHelper.java | 74 ++++++ .../TransactDeleteItemEnhancedRequest.java | 8 + .../OptimisticLockingDeleteTest.java | 144 +++++++++++ .../model/OptimisticLockingHelperTest.java | 120 +++++++++ 13 files changed, 990 insertions(+), 6 deletions(-) create mode 100644 services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java index f6c4d3fd40bf..4b7b51580265 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java @@ -15,7 +15,6 @@ package software.amazon.awssdk.enhanced.dynamodb; -import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -24,6 +23,7 @@ import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex; @@ -31,9 +31,13 @@ import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.VersionedRecord; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.Projection; import software.amazon.awssdk.services.dynamodb.model.ProjectionType; @@ -41,11 +45,13 @@ import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.ReturnValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegrationTestBase { private static final String TABLE_NAME = createTestTableName(); + private static final String VERSIONED_TABLE_NAME = createTestTableName(); private static final EnhancedLocalSecondaryIndex LOCAL_SECONDARY_INDEX = EnhancedLocalSecondaryIndex.builder() .indexName("index1") .projection(Projection.builder() @@ -56,14 +62,21 @@ public class AsyncCrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegr private static DynamoDbAsyncClient dynamoDbClient; private static DynamoDbEnhancedAsyncClient enhancedClient; private static DynamoDbAsyncTable mappedTable; + private static DynamoDbAsyncTable versionedRecordTable; @BeforeClass public static void beforeClass() { dynamoDbClient = createAsyncDynamoDbClient(); - enhancedClient = DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbClient).build(); + enhancedClient = DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(dynamoDbClient) + .extensions(VersionedRecordExtension.builder().build()) + .build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)).join(); + versionedRecordTable = enhancedClient.table(VERSIONED_TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); + versionedRecordTable.createTable().join(); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)).join(); + dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(VERSIONED_TABLE_NAME)).join(); } @After @@ -72,12 +85,18 @@ public void tearDown() { .items() .subscribe(record -> mappedTable.deleteItem(record).join()) .join(); + + versionedRecordTable.scan() + .items() + .subscribe(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord).join()) + .join(); } @AfterClass public static void afterClass() { try { dynamoDbClient.deleteTable(r -> r.tableName(TABLE_NAME)).join(); + dynamoDbClient.deleteTable(r -> r.tableName(VERSIONED_TABLE_NAME)).join(); } finally { dynamoDbClient.close(); } @@ -341,4 +360,207 @@ public void getItem_withoutReturnConsumedCapacity() { GetItemEnhancedResponse response = mappedTable.getItemWithResponse(req -> req.key(key)).join(); assertThat(response.consumedCapacity()).isNull(); } + + // ========== OPTIMISTIC LOCKING TESTS ========== + + // 1. deleteItem(T item) - Non-versioned record + @Test + public void deleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item).join(); + mappedTable.deleteItem(item).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordTable.deleteItem(savedItem).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 3. deleteItem(T item, false) - Versioned record, should not use optimistic locking + @Test + public void deleteItem_versionedRecord_flagFalse_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Delete with old version but flag=false - should succeed (no optimistic locking) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem, false).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, true) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_flagTrue_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + versionedRecordTable.deleteItem(savedItem, true).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item, true) - Versioned record, versions mismatch + @Test + public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem).join(); + + // Try to delete with old version and flag=true - should fail + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(oldVersionItem, true).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getCause()).isInstanceOf(ConditionalCheckFailedException.class)) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + + + // 6. deleteItem(DeleteItemEnhancedRequest) - Versions match + @Test + public void deleteItemWithHelper_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + + versionedRecordTable.deleteItem(requestWithLocking).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) - Versions mismatch + @Test + public void deleteItemWithHelper_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n("999").build(), "version"); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 8. TransactWriteItems.addDeleteItem(T item) - Non-versioned record + @Test + public void transactDeleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item).join(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, item) + .build()).join(); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 9. TransactWriteItems.addDeleteItem(T item) - Versioned record, versions match + @Test + public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + + + // 10. TransactWriteItems - versions match + @Test + public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + + TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()).join(); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); + assertThat(deletedItem).isNull(); + } + + // 11. TransactWriteItems with helper - versions mismatch + @Test + public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item).join(); + + TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n("999").build(), "version"); + + assertThatThrownBy(() -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build()).join()) + .isInstanceOf(CompletionException.class) + .satisfies(e -> assertThat(((TransactionCanceledException) e.getCause()) + .cancellationReasons() + .stream() + .anyMatch(reason -> "ConditionalCheckFailed".equals(reason.code()))) + .isTrue()); + } } diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java index 4685569ebf21..bd2c36e58476 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java @@ -17,12 +17,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import org.assertj.core.data.Offset; import org.junit.After; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex; @@ -30,9 +34,13 @@ import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.VersionedRecord; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.Projection; @@ -40,10 +48,12 @@ import software.amazon.awssdk.services.dynamodb.model.ReturnConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; public class CrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegrationTestBase { private static final String TABLE_NAME = createTestTableName(); + private static final String VERSIONED_TABLE_NAME = createTestTableName(); private static final EnhancedLocalSecondaryIndex LOCAL_SECONDARY_INDEX = EnhancedLocalSecondaryIndex.builder() @@ -56,14 +66,21 @@ public class CrudWithResponseIntegrationTest extends DynamoDbEnhancedIntegration private static DynamoDbClient dynamoDbClient; private static DynamoDbEnhancedClient enhancedClient; private static DynamoDbTable mappedTable; + private static DynamoDbTable versionedRecordTable; @BeforeClass public static void beforeClass() { dynamoDbClient = createDynamoDbClient(); - enhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); + enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .extensions(VersionedRecordExtension.builder().build()) + .build(); mappedTable = enhancedClient.table(TABLE_NAME, TABLE_SCHEMA); mappedTable.createTable(r -> r.localSecondaryIndices(LOCAL_SECONDARY_INDEX)); + versionedRecordTable = enhancedClient.table(VERSIONED_TABLE_NAME, VERSIONED_RECORD_TABLE_SCHEMA); + versionedRecordTable.createTable(); dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(TABLE_NAME)); + dynamoDbClient.waiter().waitUntilTableExists(r -> r.tableName(VERSIONED_TABLE_NAME)); } @After @@ -71,12 +88,17 @@ public void tearDown() { mappedTable.scan() .items() .forEach(record -> mappedTable.deleteItem(record)); + + versionedRecordTable.scan() + .items() + .forEach(versionedRecord -> versionedRecordTable.deleteItem(versionedRecord)); } @AfterClass public static void afterClass() { try { dynamoDbClient.deleteTable(r -> r.tableName(TABLE_NAME)); + dynamoDbClient.deleteTable(r -> r.tableName(VERSIONED_TABLE_NAME)); } finally { dynamoDbClient.close(); } @@ -321,4 +343,209 @@ public void getItem_set_stronglyConsistent() { // A strongly consistent read request of an item up to 4 KB requires one read request unit. assertThat(consumedCapacity.capacityUnits()).isCloseTo(20.0, Offset.offset(1.0)); } -} + + // ========== OPTIMISTIC LOCKING TESTS ========== + + // 1. deleteItem(T item) - Non-versioned record + @Test + public void deleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + mappedTable.deleteItem(item); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 2. deleteItem(T item) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + versionedRecordTable.deleteItem(savedItem); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 3. deleteItem(T item, false) - Versioned record, should not use optimistic locking + @Test + public void deleteItem_versionedRecord_flagFalse_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Delete with old version but flag=false - should succeed (no optimistic locking) + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + versionedRecordTable.deleteItem(oldVersionItem, false); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 4. deleteItem(T item, true) - Versioned record, versions match + @Test + public void deleteItem_versionedRecord_flagTrue_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + versionedRecordTable.deleteItem(savedItem, true); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 5. deleteItem(T item, true) - Versioned record, versions mismatch + @Test + public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + // Update the item to change its version + savedItem.setStringAttribute("Updated Item"); + versionedRecordTable.updateItem(savedItem); + + // Try to delete with old version and flag=true - should fail + VersionedRecord oldVersionItem = new VersionedRecord().setId("123").setSort(10).setVersion(1); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(oldVersionItem, true)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + + + + // 6. deleteItem(DeleteItemEnhancedRequest) with helper - versions match + @Test + public void deleteItemWithHelper_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + + versionedRecordTable.deleteItem(requestWithLocking); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 7. deleteItem(DeleteItemEnhancedRequest) with helper - versions mismatch + @Test + public void deleteItemWithHelper_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n("999").build(), "version"); + + assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) + .isInstanceOf(ConditionalCheckFailedException.class) + .satisfies(e -> assertThat(e.getMessage()).contains("The conditional request failed")); + } + + // 8. TransactWriteItems.addDeleteItem(T item) - Non-versioned record + @Test + public void transactDeleteItem_nonVersionedRecord_shouldSucceed() { + Record item = new Record().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + mappedTable.putItem(item); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, item) + .build()); + + Record deletedItem = mappedTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 9. TransactWriteItems.addDeleteItem(T item) - Versioned record, versions match + @Test + public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, savedItem) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + + + + // 10. TransactWriteItems with helper - versions match + @Test + public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + + TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + + enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, + requestWithLocking) + .build()); + + VersionedRecord deletedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); + assertThat(deletedItem).isNull(); + } + + // 11. TransactWriteItems with helper - versions mismatch + @Test + public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { + VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); + Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); + + versionedRecordTable.putItem(item); + + TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( + baseRequest, AttributeValue.builder().n("999").build(), "version"); + + TransactionCanceledException ex = assertThrows(TransactionCanceledException.class, + () -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(versionedRecordTable, requestWithLocking) + .build())); + + assertTrue(ex.hasCancellationReasons()); + assertEquals(1, ex.cancellationReasons().size()); + assertEquals("ConditionalCheckFailed", ex.cancellationReasons().get(0).code()); + assertEquals("The conditional request failed", ex.cancellationReasons().get(0).message()); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java index 8a8e35470c20..eb858266428c 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java @@ -15,6 +15,7 @@ package software.amazon.awssdk.enhanced.dynamodb; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primarySortKey; import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; @@ -27,6 +28,7 @@ import java.util.stream.IntStream; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; import software.amazon.awssdk.enhanced.dynamodb.model.Record; +import software.amazon.awssdk.enhanced.dynamodb.model.VersionedRecord; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.testutils.service.AwsIntegrationTestBase; @@ -75,6 +77,37 @@ protected static DynamoDbAsyncClient createAsyncDynamoDbClient() { .setter(Record::setStringAttribute)) .build(); + protected static final TableSchema VERSIONED_RECORD_TABLE_SCHEMA = + StaticTableSchema.builder(VersionedRecord.class) + .newItemSupplier(VersionedRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(VersionedRecord::getId) + .setter(VersionedRecord::setId) + .tags(primaryPartitionKey(), secondaryPartitionKey("index1"))) + .addAttribute(Integer.class, a -> a.name("sort") + .getter(VersionedRecord::getSort) + .setter(VersionedRecord::setSort) + .tags(primarySortKey(), secondarySortKey("index1"))) + .addAttribute(Integer.class, a -> a.name("value") + .getter(VersionedRecord::getValue) + .setter(VersionedRecord::setValue)) + .addAttribute(String.class, a -> a.name("gsi_id") + .getter(VersionedRecord::getGsiId) + .setter(VersionedRecord::setGsiId) + .tags(secondaryPartitionKey("gsi_keys_only"))) + .addAttribute(Integer.class, a -> a.name("gsi_sort") + .getter(VersionedRecord::getGsiSort) + .setter(VersionedRecord::setGsiSort) + .tags(secondarySortKey("gsi_keys_only"))) + .addAttribute(String.class, a -> a.name("stringAttribute") + .getter(VersionedRecord::getStringAttribute) + .setter(VersionedRecord::setStringAttribute)) + .addAttribute(Integer.class, a -> a.name("version") + .getter(VersionedRecord::getVersion) + .setter(VersionedRecord::setVersion) + .tags(versionAttribute())) + .build(); + protected static final List RECORDS = IntStream.range(0, 9) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.java new file mode 100644 index 000000000000..38ff5cbb57bc --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/model/VersionedRecord.java @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.model; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class VersionedRecord { + + private String id; + private Integer sort; + private Integer value; + private String gsiId; + private Integer gsiSort; + + private String stringAttribute; + private Integer version; + + public String getId() { + return id; + } + + public VersionedRecord setId(String id) { + this.id = id; + return this; + } + + public Integer getSort() { + return sort; + } + + public VersionedRecord setSort(Integer sort) { + this.sort = sort; + return this; + } + + public Integer getValue() { + return value; + } + + public VersionedRecord setValue(Integer value) { + this.value = value; + return this; + } + + public String getGsiId() { + return gsiId; + } + + public VersionedRecord setGsiId(String gsiId) { + this.gsiId = gsiId; + return this; + } + + public Integer getGsiSort() { + return gsiSort; + } + + public VersionedRecord setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + return this; + } + + public String getStringAttribute() { + return stringAttribute; + } + + public VersionedRecord setStringAttribute(String stringAttribute) { + this.stringAttribute = stringAttribute; + return this; + } + + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public VersionedRecord setVersion(Integer version) { + this.version = version; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + VersionedRecord versionedRecord = (VersionedRecord) o; + return Objects.equals(id, versionedRecord.id) && + Objects.equals(sort, versionedRecord.sort) && + Objects.equals(value, versionedRecord.value) && + Objects.equals(gsiId, versionedRecord.gsiId) && + Objects.equals(stringAttribute, versionedRecord.stringAttribute) && + Objects.equals(gsiSort, versionedRecord.gsiSort) && + Objects.equals(version, versionedRecord.version); + } + + @Override + public int hashCode() { + return Objects.hash(id, sort, value, gsiId, gsiSort, stringAttribute, version); + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java index e193fe681df8..135302d54b05 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java @@ -247,6 +247,10 @@ default CompletableFuture deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + default CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { + throw new UnsupportedOperationException(); + } + /** * Deletes a single item from the mapped table using a supplied primary {@link Key}. *

diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java index 6e94e6726c2f..49f19a8842dd 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java @@ -245,6 +245,10 @@ default T deleteItem(T keyItem) { throw new UnsupportedOperationException(); } + default T deleteItem(T keyItem, boolean useOptimisticLocking) { + throw new UnsupportedOperationException(); + } + /** * Deletes a single item from the mapped table using a supplied primary {@link Key}. *

diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index cd281dec3d24..5f5bab8c9f9a 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLockingIfVersioned; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -53,6 +54,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse; @@ -145,7 +147,15 @@ public CompletableFuture deleteItem(Key key) { @Override public CompletableFuture deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return deleteItem(keyItem, false); + } + + public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); + if (useOptimisticLocking) { + request = withOptimisticLockingIfVersioned(request, keyItem, tableSchema); + } + return deleteItem(request); } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index 31ce811b3483..aae8b2cfb081 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLockingIfVersioned; import java.util.ArrayList; import java.util.function.Consumer; @@ -146,7 +147,16 @@ public T deleteItem(Key key) { @Override public T deleteItem(T keyItem) { - return deleteItem(keyFrom(keyItem)); + return deleteItem(keyItem, false); + } + + @Override + public T deleteItem(T keyItem, boolean useOptimisticLocking) { + DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); + if (useOptimisticLocking) { + request = withOptimisticLockingIfVersioned(request, keyItem, tableSchema); + } + return deleteItem(request); } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java index 0a7a01500bfd..df6ab6521d63 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java @@ -24,6 +24,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; import software.amazon.awssdk.services.dynamodb.model.ReturnConsumedCapacity; @@ -145,6 +146,13 @@ public String returnValuesOnConditionCheckFailureAsString() { return returnValuesOnConditionCheckFailure; } + public static DeleteItemEnhancedRequest withOptimisticLocking( + DeleteItemEnhancedRequest request, + AttributeValue oldVersionValue, + String versionAttributeName) { + return OptimisticLockingHelper.withOptimisticLocking(request, oldVersionValue, versionAttributeName); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java new file mode 100644 index 000000000000..2431f318f776 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -0,0 +1,74 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.model; + +import java.util.Optional; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +@SdkPublicApi +public final class OptimisticLockingHelper { + + private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; + + private OptimisticLockingHelper() { + } + + public static DeleteItemEnhancedRequest withOptimisticLocking( + DeleteItemEnhancedRequest request, AttributeValue oldVersionValue, String versionAttributeName) { + + Expression conditionExpression = createVersionCondition(oldVersionValue, versionAttributeName); + return request.toBuilder() + .conditionExpression(conditionExpression) + .build(); + } + + public static TransactDeleteItemEnhancedRequest withOptimisticLocking( + TransactDeleteItemEnhancedRequest request, AttributeValue oldVersionValue, String versionAttributeName) { + + Expression conditionExpression = createVersionCondition(oldVersionValue, versionAttributeName); + return request.toBuilder() + .conditionExpression(conditionExpression) + .build(); + } + + public static DeleteItemEnhancedRequest withOptimisticLockingIfVersioned( + DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema) { + + Optional versionAttribute = getVersionAttributeName(tableSchema); + if (versionAttribute.isPresent()) { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttribute.get()); + if (version != null) { + return withOptimisticLocking(request, version, versionAttribute.get()); + } + } + return request; + } + + public static Optional getVersionAttributeName( + software.amazon.awssdk.enhanced.dynamodb.TableSchema tableSchema) { + return tableSchema.tableMetadata().customMetadataObject(CUSTOM_METADATA_KEY, String.class); + } + + private static Expression createVersionCondition(AttributeValue oldVersionValue, String versionAttributeName) { + return Expression.builder() + .expression(versionAttributeName + " = :version_value") + .putExpressionValue(":version_value", oldVersionValue) + .build(); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java index 15c4df8cacd8..647d3dafa0d6 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java @@ -24,6 +24,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; /** @@ -107,6 +108,13 @@ public String returnValuesOnConditionCheckFailureAsString() { return returnValuesOnConditionCheckFailure; } + public static TransactDeleteItemEnhancedRequest withOptimisticLocking( + TransactDeleteItemEnhancedRequest request, + AttributeValue oldVersionValue, + String versionAttributeName) { + return OptimisticLockingHelper.withOptimisticLocking(request, oldVersionValue, versionAttributeName); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java new file mode 100644 index 000000000000..908ff4fc36d7 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java @@ -0,0 +1,144 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +public class OptimisticLockingDeleteTest extends LocalDynamoDbSyncTestBase { + + @DynamoDbBean + public static class VersionedRecord { + private String id; + private String data; + private Integer version; + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getData() { return data; } + public void setData(String data) { this.data = data; } + + @DynamoDbVersionAttribute + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } + } + + private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + private DynamoDbTable mappedTable = enhancedClient.table(getConcreteTableName("versioned-table"), + TableSchema.fromClass(VersionedRecord.class)); + + @Rule + public ExpectedException exception = ExpectedException.none(); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("versioned-table")) + .build()); + } + + @Test + public void deleteItem_withKeyItem_appliesOptimisticLocking() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Get the item to obtain current version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(retrievedRecord.getVersion(), is(1)); // VersionedRecordExtension starts at 1 + + // Update the item to change version + retrievedRecord.setData("updated-data"); + mappedTable.updateItem(retrievedRecord); + + // Try to delete using old version - should fail + VersionedRecord oldVersionRecord = new VersionedRecord(); + oldVersionRecord.setId("test-id"); + oldVersionRecord.setVersion(1); // Old version + + exception.expect(ConditionalCheckFailedException.class); + mappedTable.deleteItem(oldVersionRecord); + } + + @Test + public void deleteItem_withKeyItem_succeedsWithCorrectVersion() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Get the item to obtain current version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + + // Delete using correct version - should succeed + VersionedRecord deletedRecord = mappedTable.deleteItem(retrievedRecord); + assertThat(deletedRecord.getId(), is("test-id")); + + // Verify item is deleted + VersionedRecord afterDelete = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(afterDelete, is(nullValue())); + } + + @Test + public void deleteItem_withKey_doesNotApplyOptimisticLocking() { + // Put initial item + VersionedRecord record = new VersionedRecord(); + record.setId("test-id"); + record.setData("initial-data"); + mappedTable.putItem(record); + + // Update the item to change version + VersionedRecord retrievedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + retrievedRecord.setData("updated-data"); + mappedTable.updateItem(retrievedRecord); + + // Delete using key only - should succeed regardless of version + VersionedRecord deletedRecord = mappedTable.deleteItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(deletedRecord.getId(), is("test-id")); + + // Verify item is deleted + VersionedRecord afterDelete = mappedTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(afterDelete, is(nullValue())); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java new file mode 100644 index 000000000000..59e856bc25d0 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class OptimisticLockingHelperTest { + + @Test + public void withOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { + Key key = Key.builder().partitionValue("test-id").build(); + DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder() + .key(key) + .build(); + + AttributeValue versionValue = AttributeValue.builder().n("5").build(); + String versionAttributeName = "version"; + + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( + originalRequest, versionValue, versionAttributeName); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo("version = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { + Key key = Key.builder().partitionValue("test-id").build(); + TransactDeleteItemEnhancedRequest originalRequest = TransactDeleteItemEnhancedRequest.builder() + .key(key) + .build(); + + AttributeValue versionValue = AttributeValue.builder().n("10").build(); + String versionAttributeName = "recordVersion"; + + TransactDeleteItemEnhancedRequest result = TransactDeleteItemEnhancedRequest.withOptimisticLocking( + originalRequest, versionValue, versionAttributeName); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.conditionExpression()).isNotNull(); + assertThat(result.conditionExpression().expression()).isEqualTo("recordVersion = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_preservesExistingRequestProperties() { + Key key = Key.builder().partitionValue("test-id").build(); + DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder() + .key(key) + .returnConsumedCapacity("TOTAL") + .build(); + + AttributeValue versionValue = AttributeValue.builder().n("3").build(); + + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( + originalRequest, versionValue, "version"); + + assertThat(result.key()).isEqualTo(key); + assertThat(result.returnConsumedCapacityAsString()).isEqualTo("TOTAL"); + assertThat(result.conditionExpression()).isNotNull(); + } + + @Test + public void withOptimisticLocking_differentVersionAttributeNames_shouldWork() { + Key key = Key.builder().partitionValue("test-id").build(); + DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder().key(key).build(); + AttributeValue versionValue = AttributeValue.builder().n("1").build(); + + // Test with different attribute names + String[] attributeNames = {"version", "recordVersion", "itemVersion", "v"}; + + for (String attributeName : attributeNames) { + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( + originalRequest, versionValue, attributeName); + + assertThat(result.conditionExpression().expression()).isEqualTo(attributeName + " = :version_value"); + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + } + + @Test + public void withOptimisticLocking_differentVersionValues_shouldWork() { + Key key = Key.builder().partitionValue("test-id").build(); + DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder().key(key).build(); + + // Test with different version values + AttributeValue[] versionValues = { + AttributeValue.builder().n("0").build(), + AttributeValue.builder().n("1").build(), + AttributeValue.builder().n("999").build(), + AttributeValue.builder().n("123456789").build() + }; + + for (AttributeValue versionValue : versionValues) { + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( + originalRequest, versionValue, "version"); + + assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); + } + } +} \ No newline at end of file From 4ed59ffdf26b16ec908b7195b7d2601180ed9c43 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 29 Sep 2025 16:04:49 +0300 Subject: [PATCH 2/5] [POC] Optimistic Locking for Delete Operations --- .../client/DefaultDynamoDbAsyncTable.java | 24 +++++++++++++++++-- .../internal/client/DefaultDynamoDbTable.java | 23 ++++++++++++++++-- .../model/OptimisticLockingHelper.java | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index 5f5bab8c9f9a..adb27017cf0c 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLockingIfVersioned; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLocking; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -145,15 +145,35 @@ public CompletableFuture deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * Deletes an item from the table using the provided key item. + *

+ * Note: This method does not use optimistic locking. For versioned records, + * use {@link #deleteItem(Object, boolean)} with {@code useOptimisticLocking = true} + * to enable optimistic locking protection. + *

+ * The DynamoDB Enhanced Client provides optimistic locking for the following operations: + *

    + *
  • {@link #deleteItem(Object, boolean)} - when {@code useOptimisticLocking = true}
  • + *
  • {@link #deleteItem(DeleteItemEnhancedRequest)} - when using {@code DeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • + *
  • Transaction operations - when using {@code TransactDeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • + *
+ * + * @param keyItem the item containing the key attributes to identify the item to delete + * @return a CompletableFuture containing the deleted item, or null if the item was not found + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior + */ + @Deprecated @Override public CompletableFuture deleteItem(T keyItem) { return deleteItem(keyItem, false); } + @Override public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); if (useOptimisticLocking) { - request = withOptimisticLockingIfVersioned(request, keyItem, tableSchema); + request = withOptimisticLocking(request, keyItem, tableSchema); } return deleteItem(request); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index aae8b2cfb081..e41cb514c23c 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLockingIfVersioned; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLocking; import java.util.ArrayList; import java.util.function.Consumer; @@ -145,6 +145,25 @@ public T deleteItem(Key key) { return deleteItem(r -> r.key(key)); } + /** + * Deletes an item from the table using the provided key item. + *

+ * Note: This method does not use optimistic locking. For versioned records, + * use {@link #deleteItem(Object, boolean)} with {@code useOptimisticLocking = true} + * to enable optimistic locking protection. + *

+ * The DynamoDB Enhanced Client provides optimistic locking for the following operations: + *

    + *
  • {@link #deleteItem(Object, boolean)} - when {@code useOptimisticLocking = true}
  • + *
  • {@link #deleteItem(DeleteItemEnhancedRequest)} - when using {@code DeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • + *
  • Transaction operations - when using {@code TransactDeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • + *
+ * + * @param keyItem the item containing the key attributes to identify the item to delete + * @return the deleted item, or null if the item was not found + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior + */ + @Deprecated @Override public T deleteItem(T keyItem) { return deleteItem(keyItem, false); @@ -154,7 +173,7 @@ public T deleteItem(T keyItem) { public T deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); if (useOptimisticLocking) { - request = withOptimisticLockingIfVersioned(request, keyItem, tableSchema); + request = withOptimisticLocking(request, keyItem, tableSchema); } return deleteItem(request); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java index 2431f318f776..873f251151d8 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -47,7 +47,7 @@ public static TransactDeleteItemEnhancedRequest withOptimisticLocking( .build(); } - public static DeleteItemEnhancedRequest withOptimisticLockingIfVersioned( + public static DeleteItemEnhancedRequest withOptimisticLocking( DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema) { Optional versionAttribute = getVersionAttributeName(tableSchema); From 904caa901194d5307c568aa2237c32bdf72f59c7 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 29 Sep 2025 16:54:55 +0300 Subject: [PATCH 3/5] [POC] Optimistic Locking for Delete Operations --- .../AsyncCrudWithResponseIntegrationTest.java | 28 +++++---- .../CrudWithResponseIntegrationTest.java | 28 +++++---- .../model/DeleteItemEnhancedRequest.java | 20 ++++--- .../model/OptimisticLockingHelper.java | 12 ++-- .../TransactDeleteItemEnhancedRequest.java | 19 +++--- .../model/OptimisticLockingHelperTest.java | 58 +++++++++---------- 6 files changed, 91 insertions(+), 74 deletions(-) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java index 4b7b51580265..d773f078251b 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java @@ -458,9 +458,10 @@ public void deleteItemWithHelper_versionMatch_shouldSucceed() { versionedRecordTable.putItem(item).join(); VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); - DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); - DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); versionedRecordTable.deleteItem(requestWithLocking).join(); @@ -476,9 +477,10 @@ public void deleteItemWithHelper_versionMismatch_shouldFail() { versionedRecordTable.putItem(item).join(); - DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); - DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n("999").build(), "version"); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking).join()) .isInstanceOf(CompletionException.class) @@ -529,9 +531,10 @@ public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { versionedRecordTable.putItem(item).join(); VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)).join(); - TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); - TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() .addDeleteItem(versionedRecordTable, requestWithLocking) @@ -549,9 +552,10 @@ public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { versionedRecordTable.putItem(item).join(); - TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); - TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n("999").build(), "version"); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); assertThatThrownBy(() -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() .addDeleteItem(versionedRecordTable, requestWithLocking) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java index bd2c36e58476..3d443472c8ea 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java @@ -441,9 +441,10 @@ public void deleteItemWithHelper_versionMatch_shouldSucceed() { versionedRecordTable.putItem(item); VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); - DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); - DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); versionedRecordTable.deleteItem(requestWithLocking); @@ -459,9 +460,10 @@ public void deleteItemWithHelper_versionMismatch_shouldFail() { versionedRecordTable.putItem(item); - DeleteItemEnhancedRequest baseRequest = DeleteItemEnhancedRequest.builder().key(recordKey).build(); - DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n("999").build(), "version"); + DeleteItemEnhancedRequest requestWithLocking = DeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); assertThatThrownBy(() -> versionedRecordTable.deleteItem(requestWithLocking)) .isInstanceOf(ConditionalCheckFailedException.class) @@ -513,9 +515,10 @@ public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { versionedRecordTable.putItem(item); VersionedRecord savedItem = versionedRecordTable.getItem(r -> r.key(recordKey)); - TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); - TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version"); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n(savedItem.getVersion().toString()).build(), "version") + .build(); enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() .addDeleteItem(versionedRecordTable, @@ -534,9 +537,10 @@ public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { versionedRecordTable.putItem(item); - TransactDeleteItemEnhancedRequest baseRequest = TransactDeleteItemEnhancedRequest.builder().key(recordKey).build(); - TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.withOptimisticLocking( - baseRequest, AttributeValue.builder().n("999").build(), "version"); + TransactDeleteItemEnhancedRequest requestWithLocking = TransactDeleteItemEnhancedRequest.builder() + .key(recordKey) + .withOptimisticLocking(AttributeValue.builder().n("999").build(), "version") + .build(); TransactionCanceledException ex = assertThrows(TransactionCanceledException.class, () -> enhancedClient.transactWriteItems(TransactWriteItemsEnhancedRequest.builder() diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java index df6ab6521d63..93a833a7ce49 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.createVersionCondition; + import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -146,13 +148,6 @@ public String returnValuesOnConditionCheckFailureAsString() { return returnValuesOnConditionCheckFailure; } - public static DeleteItemEnhancedRequest withOptimisticLocking( - DeleteItemEnhancedRequest request, - AttributeValue oldVersionValue, - String versionAttributeName) { - return OptimisticLockingHelper.withOptimisticLocking(request, oldVersionValue, versionAttributeName); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -297,6 +292,17 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking condition to the delete request. + * + * @param versionValue the expected version value + * @param versionAttributeName the name of the version attribute + * @return a builder of this type + */ + public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { + return conditionExpression(createVersionCondition(versionValue, versionAttributeName)); + } + public DeleteItemEnhancedRequest build() { return new DeleteItemEnhancedRequest(this); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java index 873f251151d8..6a55b5f146fd 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -30,18 +30,18 @@ private OptimisticLockingHelper() { } public static DeleteItemEnhancedRequest withOptimisticLocking( - DeleteItemEnhancedRequest request, AttributeValue oldVersionValue, String versionAttributeName) { + DeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { - Expression conditionExpression = createVersionCondition(oldVersionValue, versionAttributeName); + Expression conditionExpression = createVersionCondition(versionValue, versionAttributeName); return request.toBuilder() .conditionExpression(conditionExpression) .build(); } public static TransactDeleteItemEnhancedRequest withOptimisticLocking( - TransactDeleteItemEnhancedRequest request, AttributeValue oldVersionValue, String versionAttributeName) { + TransactDeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { - Expression conditionExpression = createVersionCondition(oldVersionValue, versionAttributeName); + Expression conditionExpression = createVersionCondition(versionValue, versionAttributeName); return request.toBuilder() .conditionExpression(conditionExpression) .build(); @@ -65,10 +65,10 @@ public static Optional getVersionAttributeName( return tableSchema.tableMetadata().customMetadataObject(CUSTOM_METADATA_KEY, String.class); } - private static Expression createVersionCondition(AttributeValue oldVersionValue, String versionAttributeName) { + public static Expression createVersionCondition(AttributeValue versionValue, String versionAttributeName) { return Expression.builder() .expression(versionAttributeName + " = :version_value") - .putExpressionValue(":version_value", oldVersionValue) + .putExpressionValue(":version_value", versionValue) .build(); } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java index 647d3dafa0d6..7977c6a7eaff 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java @@ -15,6 +15,8 @@ package software.amazon.awssdk.enhanced.dynamodb.model; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.createVersionCondition; + import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -108,13 +110,6 @@ public String returnValuesOnConditionCheckFailureAsString() { return returnValuesOnConditionCheckFailure; } - public static TransactDeleteItemEnhancedRequest withOptimisticLocking( - TransactDeleteItemEnhancedRequest request, - AttributeValue oldVersionValue, - String versionAttributeName) { - return OptimisticLockingHelper.withOptimisticLocking(request, oldVersionValue, versionAttributeName); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -223,6 +218,16 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio return this; } + /** + * Adds optimistic locking condition to the delete request. + * + * @param versionValue the expected version value + * @param versionAttributeName the name of the version attribute + * @return a builder of this type + */ + public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { + return conditionExpression(createVersionCondition(versionValue, versionAttributeName)); + } public TransactDeleteItemEnhancedRequest build() { return new TransactDeleteItemEnhancedRequest(this); diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java index 59e856bc25d0..6fcb9b6429f6 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -24,17 +24,15 @@ public class OptimisticLockingHelperTest { @Test - public void withOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { + public void builderWithOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { Key key = Key.builder().partitionValue("test-id").build(); - DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder() - .key(key) - .build(); - AttributeValue versionValue = AttributeValue.builder().n("5").build(); String versionAttributeName = "version"; - DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( - originalRequest, versionValue, versionAttributeName); + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, versionAttributeName) + .build(); assertThat(result.key()).isEqualTo(key); assertThat(result.conditionExpression()).isNotNull(); @@ -42,18 +40,18 @@ public void withOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); } + + @Test - public void withOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { + public void builderWithOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { Key key = Key.builder().partitionValue("test-id").build(); - TransactDeleteItemEnhancedRequest originalRequest = TransactDeleteItemEnhancedRequest.builder() - .key(key) - .build(); - AttributeValue versionValue = AttributeValue.builder().n("10").build(); String versionAttributeName = "recordVersion"; - TransactDeleteItemEnhancedRequest result = TransactDeleteItemEnhancedRequest.withOptimisticLocking( - originalRequest, versionValue, versionAttributeName); + TransactDeleteItemEnhancedRequest result = TransactDeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, versionAttributeName) + .build(); assertThat(result.key()).isEqualTo(key); assertThat(result.conditionExpression()).isNotNull(); @@ -62,17 +60,15 @@ public void withOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCon } @Test - public void withOptimisticLocking_preservesExistingRequestProperties() { + public void builderWithOptimisticLocking_preservesExistingRequestProperties() { Key key = Key.builder().partitionValue("test-id").build(); - DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder() - .key(key) - .returnConsumedCapacity("TOTAL") - .build(); - AttributeValue versionValue = AttributeValue.builder().n("3").build(); - DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( - originalRequest, versionValue, "version"); + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .returnConsumedCapacity("TOTAL") + .withOptimisticLocking(versionValue, "version") + .build(); assertThat(result.key()).isEqualTo(key); assertThat(result.returnConsumedCapacityAsString()).isEqualTo("TOTAL"); @@ -80,17 +76,18 @@ public void withOptimisticLocking_preservesExistingRequestProperties() { } @Test - public void withOptimisticLocking_differentVersionAttributeNames_shouldWork() { + public void builderWithOptimisticLocking_differentVersionAttributeNames_shouldWork() { Key key = Key.builder().partitionValue("test-id").build(); - DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder().key(key).build(); AttributeValue versionValue = AttributeValue.builder().n("1").build(); // Test with different attribute names String[] attributeNames = {"version", "recordVersion", "itemVersion", "v"}; for (String attributeName : attributeNames) { - DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( - originalRequest, versionValue, attributeName); + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, attributeName) + .build(); assertThat(result.conditionExpression().expression()).isEqualTo(attributeName + " = :version_value"); assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); @@ -98,9 +95,8 @@ public void withOptimisticLocking_differentVersionAttributeNames_shouldWork() { } @Test - public void withOptimisticLocking_differentVersionValues_shouldWork() { + public void builderWithOptimisticLocking_differentVersionValues_shouldWork() { Key key = Key.builder().partitionValue("test-id").build(); - DeleteItemEnhancedRequest originalRequest = DeleteItemEnhancedRequest.builder().key(key).build(); // Test with different version values AttributeValue[] versionValues = { @@ -111,8 +107,10 @@ public void withOptimisticLocking_differentVersionValues_shouldWork() { }; for (AttributeValue versionValue : versionValues) { - DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.withOptimisticLocking( - originalRequest, versionValue, "version"); + DeleteItemEnhancedRequest result = DeleteItemEnhancedRequest.builder() + .key(key) + .withOptimisticLocking(versionValue, "version") + .build(); assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); } From 5853883b32394e416fbef5f04fd4a695286f954c Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 29 Sep 2025 18:21:09 +0300 Subject: [PATCH 4/5] [POC] Optimistic Locking for Delete Operations --- .../AsyncCrudWithResponseIntegrationTest.java | 16 ++-- .../CrudWithResponseIntegrationTest.java | 16 ++-- .../client/DefaultDynamoDbAsyncTable.java | 47 +++++------ .../internal/client/DefaultDynamoDbTable.java | 47 +++++------ .../model/DeleteItemEnhancedRequest.java | 15 ++-- .../model/OptimisticLockingHelper.java | 79 ++++++++++++++++--- .../TransactDeleteItemEnhancedRequest.java | 18 +++-- .../TransactWriteItemsEnhancedRequest.java | 21 +++-- .../OptimisticLockingDeleteTest.java | 2 +- .../model/OptimisticLockingHelperTest.java | 24 ++++-- 10 files changed, 189 insertions(+), 96 deletions(-) diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java index d773f078251b..f0374b1ae058 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java @@ -449,9 +449,9 @@ public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { - // 6. deleteItem(DeleteItemEnhancedRequest) - Versions match + // 6. deleteItem(DeleteItemEnhancedRequest) with builder method - Versions match @Test - public void deleteItemWithHelper_versionMatch_shouldSucceed() { + public void deleteItemWithBuilder_versionMatch_shouldSucceed() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -469,9 +469,9 @@ public void deleteItemWithHelper_versionMatch_shouldSucceed() { assertThat(deletedItem).isNull(); } - // 7. deleteItem(DeleteItemEnhancedRequest) - Versions mismatch + // 7. deleteItem(DeleteItemEnhancedRequest) with builder method - Versions mismatch @Test - public void deleteItemWithHelper_versionMismatch_shouldFail() { + public void deleteItemWithBuilder_versionMismatch_shouldFail() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -522,9 +522,9 @@ public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { - // 10. TransactWriteItems - versions match + // 10. TransactWriteItems with builder method - versions match @Test - public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { + public void transactDeleteItemWithBuilder_versionMatch_shouldSucceed() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -544,9 +544,9 @@ public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { assertThat(deletedItem).isNull(); } - // 11. TransactWriteItems with helper - versions mismatch + // 11. TransactWriteItems with builder method - versions mismatch @Test - public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { + public void transactDeleteItemWithBuilder_versionMismatch_shouldFail() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); diff --git a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java index 3d443472c8ea..9dfd3f9dea45 100644 --- a/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java +++ b/services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java @@ -432,9 +432,9 @@ public void deleteItem_versionedRecord_flagTrue_versionMismatch_shouldFail() { - // 6. deleteItem(DeleteItemEnhancedRequest) with helper - versions match + // 6. deleteItem(DeleteItemEnhancedRequest) with builder method - versions match @Test - public void deleteItemWithHelper_versionMatch_shouldSucceed() { + public void deleteItemWithBuilder_versionMatch_shouldSucceed() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -452,9 +452,9 @@ public void deleteItemWithHelper_versionMatch_shouldSucceed() { assertThat(deletedItem).isNull(); } - // 7. deleteItem(DeleteItemEnhancedRequest) with helper - versions mismatch + // 7. deleteItem(DeleteItemEnhancedRequest) with builder method - versions mismatch @Test - public void deleteItemWithHelper_versionMismatch_shouldFail() { + public void deleteItemWithBuilder_versionMismatch_shouldFail() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -506,9 +506,9 @@ public void transactDeleteItem_versionedRecord_versionMatch_shouldSucceed() { - // 10. TransactWriteItems with helper - versions match + // 10. TransactWriteItems with builder method - versions match @Test - public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { + public void transactDeleteItemWithBuilder_versionMatch_shouldSucceed() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); @@ -529,9 +529,9 @@ public void transactDeleteItemWithHelper_versionMatch_shouldSucceed() { assertThat(deletedItem).isNull(); } - // 11. TransactWriteItems with helper - versions mismatch + // 11. TransactWriteItems with builder method - versions mismatch @Test - public void transactDeleteItemWithHelper_versionMismatch_shouldFail() { + public void transactDeleteItemWithBuilder_versionMismatch_shouldFail() { VersionedRecord item = new VersionedRecord().setId("123").setSort(10).setStringAttribute("Test Item"); Key recordKey = Key.builder().partitionValue(item.getId()).sortValue(item.getSort()).build(); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index adb27017cf0c..482b2a42353b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLocking; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.applyOptimisticLockingIfApplicable; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -126,6 +126,9 @@ public CompletableFuture createTable() { .build()); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); @@ -133,6 +136,9 @@ public CompletableFuture deleteItem(DeleteItemEnhancedRequest request) { .thenApply(DeleteItemEnhancedResponse::attributes); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public CompletableFuture deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -140,41 +146,33 @@ public CompletableFuture deleteItem(Consumer deleteItem(Key key) { return deleteItem(r -> r.key(key)); } /** - * Deletes an item from the table using the provided key item. - *

- * Note: This method does not use optimistic locking. For versioned records, - * use {@link #deleteItem(Object, boolean)} with {@code useOptimisticLocking = true} - * to enable optimistic locking protection. - *

- * The DynamoDB Enhanced Client provides optimistic locking for the following operations: - *

    - *
  • {@link #deleteItem(Object, boolean)} - when {@code useOptimisticLocking = true}
  • - *
  • {@link #deleteItem(DeleteItemEnhancedRequest)} - when using {@code DeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • - *
  • Transaction operations - when using {@code TransactDeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • - *
- * - * @param keyItem the item containing the key attributes to identify the item to delete - * @return a CompletableFuture containing the deleted item, or null if the item was not found - * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior. */ - @Deprecated @Override + @Deprecated public CompletableFuture deleteItem(T keyItem) { return deleteItem(keyItem, false); } - @Override + /** + * Deletes an item from the table with optional optimistic locking. + * + * @param keyItem the item containing the key to delete + * @param useOptimisticLocking if true, applies optimistic locking if the item has version information + * @return a CompletableFuture containing the deleted item, or null if the item was not found + */ public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); - if (useOptimisticLocking) { - request = withOptimisticLocking(request, keyItem, tableSchema); - } + request = applyOptimisticLockingIfApplicable(request, keyItem, tableSchema, useOptimisticLocking); return deleteItem(request); } @@ -341,6 +339,11 @@ public CompletableFuture updateItem(T item) { public Key keyFrom(T item) { return createKeyFromItem(item, tableSchema, TableMetadata.primaryIndexName()); } + + private java.util.Optional getVersionAttributeName() { + return tableSchema.tableMetadata() + .customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } @Override diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index e41cb514c23c..371118e2dc06 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.withOptimisticLocking; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.applyOptimisticLockingIfApplicable; import java.util.ArrayList; import java.util.function.Consumer; @@ -127,12 +127,18 @@ public void createTable() { .build()); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public T deleteItem(DeleteItemEnhancedRequest request) { TableOperation> operation = DeleteItemOperation.create(request); return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient).attributes(); } + /** + * Supports optimistic locking via {@link software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper}. + */ @Override public T deleteItem(Consumer requestConsumer) { DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder(); @@ -140,41 +146,33 @@ public T deleteItem(Consumer requestConsumer) return deleteItem(builder.build()); } + /** + * Does not support optimistic locking. Use {@link #deleteItem(Object, boolean)} for optimistic locking support. + */ @Override public T deleteItem(Key key) { return deleteItem(r -> r.key(key)); } /** - * Deletes an item from the table using the provided key item. - *

- * Note: This method does not use optimistic locking. For versioned records, - * use {@link #deleteItem(Object, boolean)} with {@code useOptimisticLocking = true} - * to enable optimistic locking protection. - *

- * The DynamoDB Enhanced Client provides optimistic locking for the following operations: - *

    - *
  • {@link #deleteItem(Object, boolean)} - when {@code useOptimisticLocking = true}
  • - *
  • {@link #deleteItem(DeleteItemEnhancedRequest)} - when using {@code DeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • - *
  • Transaction operations - when using {@code TransactDeleteItemEnhancedRequest.Builder.withOptimisticLocking()}
  • - *
- * - * @param keyItem the item containing the key attributes to identify the item to delete - * @return the deleted item, or null if the item was not found - * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior + * @deprecated Use {@link #deleteItem(Object, boolean)} instead to explicitly control optimistic locking behavior. */ - @Deprecated @Override + @Deprecated public T deleteItem(T keyItem) { return deleteItem(keyItem, false); } - @Override + /** + * Deletes an item from the table with optional optimistic locking. + * + * @param keyItem the item containing the key to delete + * @param useOptimisticLocking if true, applies optimistic locking if the item has version information + * @return the deleted item, or null if the item was not found + */ public T deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); - if (useOptimisticLocking) { - request = withOptimisticLocking(request, keyItem, tableSchema); - } + request = applyOptimisticLockingIfApplicable(request, keyItem, tableSchema, useOptimisticLocking); return deleteItem(request); } @@ -333,6 +331,11 @@ public UpdateItemEnhancedResponse updateItemWithResponse(Consumer getVersionAttributeName() { + return tableSchema.tableMetadata() + .customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } @Override public void deleteTable() { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java index 93a833a7ce49..1874ef91ccfb 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DeleteItemEnhancedRequest.java @@ -293,14 +293,19 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio } /** - * Adds optimistic locking condition to the delete request. + * Adds optimistic locking to this delete request. + *

+ * This method applies a condition expression that ensures the delete operation only succeeds + * if the version attribute of the item matches the provided expected value. * - * @param versionValue the expected version value - * @param versionAttributeName the name of the version attribute - * @return a builder of this type + * @param versionValue the expected version value that must match for the delete to succeed + * @param versionAttributeName the name of the version attribute in the DynamoDB table + * @return a builder of this type with optimistic locking condition applied + * @throws IllegalArgumentException if any parameter is null */ public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { - return conditionExpression(createVersionCondition(versionValue, versionAttributeName)); + Expression optimisticLockingCondition = createVersionCondition(versionValue, versionAttributeName); + return conditionExpression(optimisticLockingCondition); } public DeleteItemEnhancedRequest build() { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java index 6a55b5f146fd..817d275f7b84 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -21,14 +21,26 @@ import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +/** + * Utility class for adding optimistic locking to DynamoDB delete operations. + *

+ * Optimistic locking prevents concurrent modifications by checking that an item's version hasn't changed since it was last read. + * If the version has changed, the delete operation fails with a {@code ConditionalCheckFailedException}. + */ @SdkPublicApi public final class OptimisticLockingHelper { - private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; - private OptimisticLockingHelper() { } + /** + * Adds optimistic locking to a delete request. + * + * @param request the original delete request + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return delete request with optimistic locking condition + */ public static DeleteItemEnhancedRequest withOptimisticLocking( DeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { @@ -38,6 +50,14 @@ public static DeleteItemEnhancedRequest withOptimisticLocking( .build(); } + /** + * Adds optimistic locking to a transactional delete request. + * + * @param request the original transactional delete request + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return transactional delete request with optimistic locking condition + */ public static TransactDeleteItemEnhancedRequest withOptimisticLocking( TransactDeleteItemEnhancedRequest request, AttributeValue versionValue, String versionAttributeName) { @@ -47,24 +67,61 @@ public static TransactDeleteItemEnhancedRequest withOptimisticLocking( .build(); } - public static DeleteItemEnhancedRequest withOptimisticLocking( + /** + * Applies optimistic locking if the item has version information. + * + * @param the type of the item + * @param request the original delete request + * @param keyItem the item containing version information + * @param tableSchema the table schema + * @return delete request with optimistic locking if version exists, otherwise original request + */ + public static DeleteItemEnhancedRequest applyOptimisticLockingIfApplicable( DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema) { - - Optional versionAttribute = getVersionAttributeName(tableSchema); - if (versionAttribute.isPresent()) { - AttributeValue version = tableSchema.attributeValue(keyItem, versionAttribute.get()); + + Optional versionAttributeName = getVersionAttributeName(tableSchema); + if (versionAttributeName.isPresent()) { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName.get()); if (version != null) { - return withOptimisticLocking(request, version, versionAttribute.get()); + return withOptimisticLocking(request, version, versionAttributeName.get()); } } return request; } - public static Optional getVersionAttributeName( - software.amazon.awssdk.enhanced.dynamodb.TableSchema tableSchema) { - return tableSchema.tableMetadata().customMetadataObject(CUSTOM_METADATA_KEY, String.class); + /** + * Conditionally applies optimistic locking if enabled and version information exists. + * + * @param the type of the item + * @param request the original delete request + * @param keyItem the item containing version information + * @param tableSchema the table schema + * @param useOptimisticLocking if true, applies optimistic locking + * @return delete request with optimistic locking if enabled and version exists, otherwise original request + */ + public static DeleteItemEnhancedRequest applyOptimisticLockingIfApplicable( + DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema, boolean useOptimisticLocking) { + return useOptimisticLocking ? applyOptimisticLockingIfApplicable(request, keyItem, tableSchema) : request; + } + + /** + * Gets the version attribute name from table schema. + * + * @param the type of the item + * @param tableSchema the table schema + * @return version attribute name if present, empty otherwise + */ + public static Optional getVersionAttributeName(TableSchema tableSchema) { + return tableSchema.tableMetadata().customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); } + /** + * Creates a version condition expression. + * + * @param versionValue the expected version value + * @param versionAttributeName the version attribute name + * @return version check condition expression + */ public static Expression createVersionCondition(AttributeValue versionValue, String versionAttributeName) { return Expression.builder() .expression(versionAttributeName + " = :version_value") diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java index 7977c6a7eaff..6e1c3a9e5f4b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactDeleteItemEnhancedRequest.java @@ -15,8 +15,6 @@ package software.amazon.awssdk.enhanced.dynamodb.model; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.createVersionCondition; - import java.util.Objects; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -219,14 +217,20 @@ public Builder returnValuesOnConditionCheckFailure(String returnValuesOnConditio } /** - * Adds optimistic locking condition to the delete request. + * Adds optimistic locking to this transactional delete request. + *

+ * This method applies a condition expression that ensures the delete operation only succeeds + * if the version attribute of the item matches the provided expected value. If the condition + * fails, the entire transaction will be cancelled. * - * @param versionValue the expected version value - * @param versionAttributeName the name of the version attribute - * @return a builder of this type + * @param versionValue the expected version value that must match for the delete to succeed + * @param versionAttributeName the name of the version attribute in the DynamoDB table + * @return a builder of this type with optimistic locking condition applied + * @throws IllegalArgumentException if any parameter is null */ public Builder withOptimisticLocking(AttributeValue versionValue, String versionAttributeName) { - return conditionExpression(createVersionCondition(versionValue, versionAttributeName)); + Expression optimisticLockingCondition = OptimisticLockingHelper.createVersionCondition(versionValue, versionAttributeName); + return conditionExpression(optimisticLockingCondition); } public TransactDeleteItemEnhancedRequest build() { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java index f322dd67dde2..a7498b686dd5 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactWriteItemsEnhancedRequest.java @@ -246,6 +246,10 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Del * the delete action, see the low-level operation description in for instance * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)} and how to construct the low-level request in * {@link TransactDeleteItemEnhancedRequest}. + *

+ * For optimistic locking support, use + * {@link TransactDeleteItemEnhancedRequest.Builder#withOptimisticLocking(software.amazon.awssdk.services.dynamodb.model.AttributeValue, String)} + * to create a request with version checking conditions before adding it to the transaction. * * @param mappedTableResource the table where the key is located * @param request A {@link TransactDeleteItemEnhancedRequest} @@ -272,13 +276,20 @@ public Builder addDeleteItem(MappedTableResource mappedTableResource, Key } /** - * Adds a primary lookup key for the item to delete, and it's associated table, to the transaction. For more information - * on the delete action, see the low-level operation description in for instance + * Adds the supplied item and its associated table to the transaction for deletion. + *

+ * Unlike {@link #addDeleteItem(MappedTableResource, Key)}, this variant allows you to provide the full modeled item + * instead of only its primary key. If the table is configured with a version attribute (for example, when using the + * {@code VersionedRecordExtension}), the enhanced client will apply + * optimistic locking semantics to ensure that the delete operation only succeeds if the + * provided item’s version matches the one currently stored in the table. + *

+ * For more information on the delete action, see the low-level operation description in for instance * {@link DynamoDbTable#deleteItem(DeleteItemEnhancedRequest)}. * - * @param mappedTableResource the table where the key is located - * @param keyItem an item that will have its key fields used to match a record to retrieve from the database - * @param the type of modelled objects in the table + * @param mappedTableResource the table where the item is located + * @param keyItem the modeled item to be deleted as part of the transaction + * @param the type of modeled objects in the table * @return a builder of this type */ public Builder addDeleteItem(MappedTableResource mappedTableResource, T keyItem) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java index 908ff4fc36d7..e491fc9daa22 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/OptimisticLockingDeleteTest.java @@ -97,7 +97,7 @@ public void deleteItem_withKeyItem_appliesOptimisticLocking() { oldVersionRecord.setVersion(1); // Old version exception.expect(ConditionalCheckFailedException.class); - mappedTable.deleteItem(oldVersionRecord); + mappedTable.deleteItem(oldVersionRecord, true); } @Test diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java index 6fcb9b6429f6..f0e5f4b6d468 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelperTest.java @@ -24,7 +24,19 @@ public class OptimisticLockingHelperTest { @Test - public void builderWithOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { + public void createVersionCondition_shouldCreateCorrectExpression() { + AttributeValue versionValue = AttributeValue.builder().n("5").build(); + String versionAttributeName = "version"; + + software.amazon.awssdk.enhanced.dynamodb.Expression result = + OptimisticLockingHelper.createVersionCondition(versionValue, versionAttributeName); + + assertThat(result.expression()).isEqualTo("version = :version_value"); + assertThat(result.expressionValues()).containsEntry(":version_value", versionValue); + } + + @Test + public void withOptimisticLocking_deleteItemEnhancedRequest_shouldAddCondition() { Key key = Key.builder().partitionValue("test-id").build(); AttributeValue versionValue = AttributeValue.builder().n("5").build(); String versionAttributeName = "version"; @@ -40,10 +52,8 @@ public void builderWithOptimisticLocking_deleteItemEnhancedRequest_shouldAddCond assertThat(result.conditionExpression().expressionValues()).containsEntry(":version_value", versionValue); } - - @Test - public void builderWithOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { + public void withOptimisticLocking_transactDeleteItemEnhancedRequest_shouldAddCondition() { Key key = Key.builder().partitionValue("test-id").build(); AttributeValue versionValue = AttributeValue.builder().n("10").build(); String versionAttributeName = "recordVersion"; @@ -60,7 +70,7 @@ public void builderWithOptimisticLocking_transactDeleteItemEnhancedRequest_shoul } @Test - public void builderWithOptimisticLocking_preservesExistingRequestProperties() { + public void withOptimisticLocking_preservesExistingRequestProperties() { Key key = Key.builder().partitionValue("test-id").build(); AttributeValue versionValue = AttributeValue.builder().n("3").build(); @@ -76,7 +86,7 @@ public void builderWithOptimisticLocking_preservesExistingRequestProperties() { } @Test - public void builderWithOptimisticLocking_differentVersionAttributeNames_shouldWork() { + public void withOptimisticLocking_differentVersionAttributeNames_shouldWork() { Key key = Key.builder().partitionValue("test-id").build(); AttributeValue versionValue = AttributeValue.builder().n("1").build(); @@ -95,7 +105,7 @@ public void builderWithOptimisticLocking_differentVersionAttributeNames_shouldWo } @Test - public void builderWithOptimisticLocking_differentVersionValues_shouldWork() { + public void withOptimisticLocking_differentVersionValues_shouldWork() { Key key = Key.builder().partitionValue("test-id").build(); // Test with different version values From bf6decc45510b852c48daa6ad687a5eea7d3238c Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 29 Sep 2025 22:11:04 +0300 Subject: [PATCH 5/5] [POC] Optimistic Locking for Delete Operations --- .../client/DefaultDynamoDbAsyncTable.java | 5 +- .../internal/client/DefaultDynamoDbTable.java | 4 +- .../model/OptimisticLockingHelper.java | 56 ++++++++----------- 3 files changed, 26 insertions(+), 39 deletions(-) diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index 482b2a42353b..3ecdae547928 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.applyOptimisticLockingIfApplicable; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @@ -54,7 +54,6 @@ import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse; @@ -172,7 +171,7 @@ public CompletableFuture deleteItem(T keyItem) { */ public CompletableFuture deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); - request = applyOptimisticLockingIfApplicable(request, keyItem, tableSchema, useOptimisticLocking); + request = conditionallyApplyOptimisticLocking(request, keyItem, tableSchema, useOptimisticLocking); return deleteItem(request); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index 371118e2dc06..f208e747d16a 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -16,7 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.client; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.createKeyFromItem; -import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.applyOptimisticLockingIfApplicable; +import static software.amazon.awssdk.enhanced.dynamodb.model.OptimisticLockingHelper.conditionallyApplyOptimisticLocking; import java.util.ArrayList; import java.util.function.Consumer; @@ -172,7 +172,7 @@ public T deleteItem(T keyItem) { */ public T deleteItem(T keyItem, boolean useOptimisticLocking) { DeleteItemEnhancedRequest request = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem)).build(); - request = applyOptimisticLockingIfApplicable(request, keyItem, tableSchema, useOptimisticLocking); + request = conditionallyApplyOptimisticLocking(request, keyItem, tableSchema, useOptimisticLocking); return deleteItem(request); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java index 817d275f7b84..b7ce047146a4 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/OptimisticLockingHelper.java @@ -67,28 +67,6 @@ public static TransactDeleteItemEnhancedRequest withOptimisticLocking( .build(); } - /** - * Applies optimistic locking if the item has version information. - * - * @param the type of the item - * @param request the original delete request - * @param keyItem the item containing version information - * @param tableSchema the table schema - * @return delete request with optimistic locking if version exists, otherwise original request - */ - public static DeleteItemEnhancedRequest applyOptimisticLockingIfApplicable( - DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema) { - - Optional versionAttributeName = getVersionAttributeName(tableSchema); - if (versionAttributeName.isPresent()) { - AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName.get()); - if (version != null) { - return withOptimisticLocking(request, version, versionAttributeName.get()); - } - } - return request; - } - /** * Conditionally applies optimistic locking if enabled and version information exists. * @@ -99,20 +77,19 @@ public static DeleteItemEnhancedRequest applyOptimisticLockingIfApplicable( * @param useOptimisticLocking if true, applies optimistic locking * @return delete request with optimistic locking if enabled and version exists, otherwise original request */ - public static DeleteItemEnhancedRequest applyOptimisticLockingIfApplicable( + public static DeleteItemEnhancedRequest conditionallyApplyOptimisticLocking( DeleteItemEnhancedRequest request, T keyItem, TableSchema tableSchema, boolean useOptimisticLocking) { - return useOptimisticLocking ? applyOptimisticLockingIfApplicable(request, keyItem, tableSchema) : request; - } - /** - * Gets the version attribute name from table schema. - * - * @param the type of the item - * @param tableSchema the table schema - * @return version attribute name if present, empty otherwise - */ - public static Optional getVersionAttributeName(TableSchema tableSchema) { - return tableSchema.tableMetadata().customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + if (!useOptimisticLocking) { + return request; + } + + return getVersionAttributeName(tableSchema) + .map(versionAttributeName -> { + AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName); + return version != null ? withOptimisticLocking(request, version, versionAttributeName) : request; + }) + .orElse(request); } /** @@ -128,4 +105,15 @@ public static Expression createVersionCondition(AttributeValue versionValue, Str .putExpressionValue(":version_value", versionValue) .build(); } + + /** + * Gets the version attribute name from table schema. + * + * @param the type of the item + * @param tableSchema the table schema + * @return version attribute name if present, empty otherwise + */ + public static Optional getVersionAttributeName(TableSchema tableSchema) { + return tableSchema.tableMetadata().customMetadataObject("VersionedRecordExtension:VersionAttribute", String.class); + } } \ No newline at end of file