Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions services-custom/dynamodb-enhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,14 @@ number tag a numeric attribute in the TableSchema:
public Integer getVersion() {...};
public void setVersion(Integer version) {...};
```

To enable optimistic locking on delete operations, set `useVersionOnDelete = true`:
```java
@DynamoDbVersionAttribute(useVersionOnDelete = true)
public Integer getVersion() {...};
public void setVersion(Integer version) {...};
```

Or using a StaticTableSchema:
```java
.addAttribute(Integer.class, a -> a.name("version")
Expand All @@ -349,6 +357,15 @@ Or using a StaticTableSchema:
.tags(versionAttribute())
```

For delete optimistic locking with StaticTableSchema:
```java
.addAttribute(Integer.class, a -> a.name("version")
.getter(Customer::getVersion)
.setter(Customer::setVersion)
// Apply the 'version' tag with delete locking enabled
.tags(versionAttribute(0L, 1L, true))
```

### AtomicCounterExtension

This extension is loaded by default and will increment numerical attributes each time records are written to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -113,8 +112,8 @@ public void putItem_returnItemCollectionMetrics_set_itemCollectionMetricsNotNull
public void updateItem_returnItemCollectionMetrics_set_itemCollectionMetricsNull() {
Record record = new Record().setId("1").setSort(10);
UpdateItemEnhancedRequest<Record> request = UpdateItemEnhancedRequest.builder(Record.class)
.item(record)
.build();
.item(record)
.build();

UpdateItemEnhancedResponse<Record> response = mappedTable.updateItemWithResponse(request).join();

Expand Down Expand Up @@ -196,8 +195,8 @@ public void updateItem_returnValues_all_old() {


UpdateItemEnhancedResponse<Record> response = mappedTable.updateItemWithResponse(r -> r.item(updatedRecord)
.returnValues(ReturnValue.ALL_OLD))
.join();
.returnValues(ReturnValue.ALL_OLD))
.join();

assertThat(response.attributes().getId()).isEqualTo(record.getId());
assertThat(response.attributes().getSort()).isEqualTo(record.getSort());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ default CompletableFuture<T> deleteItem(Key key) {
* This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for
* further details and constraints.
* <p>
* <b>Optimistic Locking:</b> If the item has a version attribute annotated with
* {@link software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute} and
* {@code useVersionOnDelete = true}, optimistic locking will be automatically applied. The deletion will only
* succeed if the version matches the current version in the database.
* <p>
* Example:
* <pre>
* {@code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ default T deleteItem(Key key) {
* This operation calls the low-level DynamoDB API DeleteItem operation. Consult the DeleteItem documentation for
* further details and constraints.
* <p>
* For versioned records, optimistic locking behavior is controlled by the {@code useVersionOnDelete} parameter
* in the {@link software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute} annotation.
* <p>
* Example:
* <pre>
* {@code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,33 @@ public static StaticAttributeTag versionAttribute() {
}

public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) {
return new VersionAttribute(startAt, incrementBy);
return new VersionAttribute(startAt, incrementBy, false);
}

public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy, Boolean useVersionOnDelete) {
return new VersionAttribute(startAt, incrementBy, useVersionOnDelete);
}
}

private static final class VersionAttribute implements StaticAttributeTag {
private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt";
private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy";
private static final String USE_VERSION_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:UseVersionOnDelete";

private final Long startAt;
private final Long incrementBy;
private final Boolean useVersionOnDelete;

private VersionAttribute() {
this.startAt = null;
this.incrementBy = null;
this.useVersionOnDelete = null;
}

private VersionAttribute(Long startAt, Long incrementBy) {
private VersionAttribute(Long startAt, Long incrementBy, Boolean useVersionOnDelete) {
this.startAt = startAt;
this.incrementBy = incrementBy;
this.useVersionOnDelete = useVersionOnDelete;
}

@Override
Expand All @@ -137,6 +145,7 @@ public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName
return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName)
.addCustomMetadataObject(START_AT_METADATA_KEY, startAt)
.addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy)
.addCustomMetadataObject(USE_VERSION_ON_DELETE_METADATA_KEY, useVersionOnDelete)
.markAttributeAsKey(attributeName, attributeValueType);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@
*/
long incrementBy() default 1;

/**
* Whether to use version checking on delete operations. When true, delete operations will apply optimistic locking using the
* version attribute. Default value - {@code false} for backwards compatibility.
*
* @return true if version checking should be used on delete operations
*/
boolean useVersionOnDelete() default false;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* 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.internal;

import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef;
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef;

import java.util.Collections;
import java.util.Optional;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.enhanced.dynamodb.Expression;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.TransactDeleteItemEnhancedRequest;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

/**
* Utility class for adding optimistic locking to DynamoDB delete operations.
* <p>
* 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}.
*/
@SdkInternalApi
public final class OptimisticLockingHelper {

private static final String CUSTOM_VERSION_METADATA_KEY = "VersionedRecordExtension:VersionAttribute";
private static final String USE_VERSION_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:UseVersionOnDelete";

private OptimisticLockingHelper() {
}

/**
* Adds optimistic locking to a delete request.
* <p>
* If a condition expression is already set on the request builder, this method merges it with the optimistic locking
* condition using {@code AND}.
*
* @param requestBuilder the original delete request builder
* @param versionValue the expected version value
* @param versionAttributeName the version attribute name
* @return delete request with optimistic locking condition
*/
public static DeleteItemEnhancedRequest optimisticLocking(DeleteItemEnhancedRequest.Builder requestBuilder,
AttributeValue versionValue, String versionAttributeName) {

Expression mergedCondition = mergeConditions(
requestBuilder.build().conditionExpression(),
createVersionCondition(versionValue, versionAttributeName));

return requestBuilder
.conditionExpression(mergedCondition)
.build();
}

/**
* Adds optimistic locking to a transactional delete request.
* <p>
* If a condition expression is already set on the request builder, this method merges it with the optimistic locking
* condition using {@code AND}.
*
* @param requestBuilder the original delete request builder
* @param versionValue the expected version value
* @param versionAttributeName the version attribute name
* @return transactional delete request with optimistic locking condition
*/
public static TransactDeleteItemEnhancedRequest optimisticLocking(TransactDeleteItemEnhancedRequest.Builder requestBuilder,
AttributeValue versionValue, String versionAttributeName) {

Expression mergedCondition = mergeConditions(
requestBuilder.build().conditionExpression(),
createVersionCondition(versionValue, versionAttributeName));

return requestBuilder
.conditionExpression(mergedCondition)
.build();
}

/**
* Conditionally applies optimistic locking based on annotation setting.
*
* @param <T> the type of the item
* @param requestBuilder the delete request builder
* @param keyItem the item containing version information
* @param tableSchema the table schema
* @return delete request with optimistic locking if annotation enables it and version exists, otherwise original request
* @throws IllegalStateException if optimistic locking is enabled but the version attribute is null
*/
public static <T> DeleteItemEnhancedRequest conditionallyApplyOptimisticLocking(
DeleteItemEnhancedRequest.Builder requestBuilder, T keyItem, TableSchema<T> tableSchema) {

return getVersionAttributeName(tableSchema)
.map(versionAttributeName -> {
Boolean useVersionOnDelete = tableSchema.tableMetadata()
.customMetadataObject(USE_VERSION_ON_DELETE_METADATA_KEY, Boolean.class)
.orElse(false);

if (!useVersionOnDelete) {
return requestBuilder.build();
}

AttributeValue version = tableSchema.attributeValue(keyItem, versionAttributeName);
if (version == null) {
throw new IllegalStateException(
"Optimistic locking is enabled for delete, but version attribute is null: " + versionAttributeName);
}
return optimisticLocking(requestBuilder, version, versionAttributeName);

}).orElseGet(requestBuilder::build);
}

/**
* Creates a version condition expression.
*
* @param versionValue the expected version value
* @param versionAttributeName the version attribute name
* @return version check condition expression
* @throws IllegalArgumentException if {@code versionAttributeName} or {@code versionValue} are null or empty
*/
public static Expression createVersionCondition(AttributeValue versionValue, String versionAttributeName) {
if (versionAttributeName == null || versionAttributeName.trim().isEmpty()) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we checking the versionAttributeName but not versionValue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a check for "versionValue" also:

if (versionAttributeName == null || versionAttributeName.trim().isEmpty()) {
     throw new IllegalArgumentException("Version attribute name must not be null or empty.");
}

if (versionValue == null || versionValue.n() == null || versionValue.n().trim().isEmpty()) {
    throw new IllegalArgumentException("Version value must not be null or empty.");
}

throw new IllegalArgumentException("Version attribute name must not be null or empty.");
}

if (versionValue == null || versionValue.n() == null || versionValue.n().trim().isEmpty()) {
throw new IllegalArgumentException("Version value must not be null or empty.");
}

String attributeKeyRef = keyRef(versionAttributeName);
String attributeValueRef = valueRef(versionAttributeName);

return Expression.builder()
.expression(String.format("%s = %s", attributeKeyRef, attributeValueRef))
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeName))
.expressionValues(Collections.singletonMap(attributeValueRef, versionValue))
.build();
}

public static Expression mergeConditions(Expression initialCondition, Expression optimisticLockingCondition) {
return Expression.join(initialCondition, optimisticLockingCondition, " AND ");
}

/**
* Gets the version attribute name from table schema.
*
* @param <T> the type of the item
* @param tableSchema the table schema
* @return version attribute name if present, empty otherwise
*/
public static <T> Optional<String> getVersionAttributeName(TableSchema<T> tableSchema) {
return tableSchema.tableMetadata().customMetadataObject(CUSTOM_VERSION_METADATA_KEY, String.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.internal.OptimisticLockingHelper.conditionallyApplyOptimisticLocking;

import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
Expand All @@ -26,6 +27,8 @@
import software.amazon.awssdk.enhanced.dynamodb.Key;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute;
import software.amazon.awssdk.enhanced.dynamodb.internal.OptimisticLockingHelper;
import software.amazon.awssdk.enhanced.dynamodb.internal.TableIndices;
import software.amazon.awssdk.enhanced.dynamodb.internal.operations.CreateTableOperation;
import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteItemOperation;
Expand Down Expand Up @@ -124,28 +127,45 @@ public CompletableFuture<Void> createTable() {
.build());
}

/**
* Supports optimistic locking via {@link OptimisticLockingHelper}.
*/
@Override
public CompletableFuture<T> deleteItem(DeleteItemEnhancedRequest request) {
TableOperation<T, ?, ?, DeleteItemEnhancedResponse<T>> operation = DeleteItemOperation.create(request);
return operation.executeOnPrimaryIndexAsync(tableSchema, tableName, extension, dynamoDbClient)
.thenApply(DeleteItemEnhancedResponse::attributes);
}

/**
* Supports optimistic locking via {@link OptimisticLockingHelper}.
*/
@Override
public CompletableFuture<T> deleteItem(Consumer<DeleteItemEnhancedRequest.Builder> requestConsumer) {
DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder();
requestConsumer.accept(builder);
return deleteItem(builder.build());
}

/**
* Does not support optimistic locking.
*/
@Override
public CompletableFuture<T> deleteItem(Key key) {
return deleteItem(r -> r.key(key));
}

/**
* Deletes an item from the table.
* <p>
* <b>Optimistic Locking:</b> If the item has a version attribute annotated with
* {@link DynamoDbVersionAttribute} and {@code useVersionOnDelete = true}, optimistic locking will be automatically applied.
*/
@Override
public CompletableFuture<T> deleteItem(T keyItem) {
return deleteItem(keyFrom(keyItem));
DeleteItemEnhancedRequest.Builder builder = DeleteItemEnhancedRequest.builder().key(keyFrom(keyItem));
DeleteItemEnhancedRequest request = conditionallyApplyOptimisticLocking(builder, keyItem, tableSchema);
return deleteItem(request);
}

@Override
Expand Down
Loading