Skip to content

Commit 078f16d

Browse files
committed
Optimistic locking for delete scenario with DeleteItemEnhancedRequest and TransactWriteItemsEnhancedRequest
1 parent 8966845 commit 078f16d

File tree

16 files changed

+1398
-17
lines changed

16 files changed

+1398
-17
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "Amazon DynamoDB Enhanced Client",
4+
"contributor": "",
5+
"description": "Optimistic delete while using DynamoDbEnhancedClient - DeleteItem with DeleteItemEnhancedRequest and TransactWriteItemsEnhancedRequest"
6+
}

services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/AsyncCrudWithResponseIntegrationTest.java

Lines changed: 325 additions & 1 deletion
Large diffs are not rendered by default.

services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/CrudWithResponseIntegrationTest.java

Lines changed: 324 additions & 0 deletions
Large diffs are not rendered by default.

services-custom/dynamodb-enhanced/src/it/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbEnhancedIntegrationTestBase.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
package software.amazon.awssdk.enhanced.dynamodb;
1717

18+
import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute;
1819
import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey;
1920
import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primarySortKey;
2021
import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey;
@@ -27,6 +28,7 @@
2728
import java.util.stream.IntStream;
2829
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema;
2930
import software.amazon.awssdk.enhanced.dynamodb.model.Record;
31+
import software.amazon.awssdk.enhanced.dynamodb.model.VersionedRecord;
3032
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
3133
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
3234
import software.amazon.awssdk.testutils.service.AwsIntegrationTestBase;
@@ -75,6 +77,37 @@ protected static DynamoDbAsyncClient createAsyncDynamoDbClient() {
7577
.setter(Record::setStringAttribute))
7678
.build();
7779

80+
protected static final TableSchema<VersionedRecord> VERSIONED_RECORD_TABLE_SCHEMA =
81+
StaticTableSchema.builder(VersionedRecord.class)
82+
.newItemSupplier(VersionedRecord::new)
83+
.addAttribute(String.class, a -> a.name("id")
84+
.getter(VersionedRecord::getId)
85+
.setter(VersionedRecord::setId)
86+
.tags(primaryPartitionKey(), secondaryPartitionKey("index1")))
87+
.addAttribute(Integer.class, a -> a.name("sort")
88+
.getter(VersionedRecord::getSort)
89+
.setter(VersionedRecord::setSort)
90+
.tags(primarySortKey(), secondarySortKey("index1")))
91+
.addAttribute(Integer.class, a -> a.name("value")
92+
.getter(VersionedRecord::getValue)
93+
.setter(VersionedRecord::setValue))
94+
.addAttribute(String.class, a -> a.name("gsi_id")
95+
.getter(VersionedRecord::getGsiId)
96+
.setter(VersionedRecord::setGsiId)
97+
.tags(secondaryPartitionKey("gsi_keys_only")))
98+
.addAttribute(Integer.class, a -> a.name("gsi_sort")
99+
.getter(VersionedRecord::getGsiSort)
100+
.setter(VersionedRecord::setGsiSort)
101+
.tags(secondarySortKey("gsi_keys_only")))
102+
.addAttribute(String.class, a -> a.name("stringAttribute")
103+
.getter(VersionedRecord::getStringAttribute)
104+
.setter(VersionedRecord::setStringAttribute))
105+
.addAttribute(Integer.class, a -> a.name("version")
106+
.getter(VersionedRecord::getVersion)
107+
.setter(VersionedRecord::setVersion)
108+
.tags(versionAttribute()))
109+
.build();
110+
78111

79112
protected static final List<Record> RECORDS =
80113
IntStream.range(0, 9)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.enhanced.dynamodb.model;
17+
18+
import java.util.Objects;
19+
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute;
20+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
21+
22+
@DynamoDbBean
23+
public class VersionedRecord {
24+
25+
private String id;
26+
private Integer sort;
27+
private Integer value;
28+
private String gsiId;
29+
private Integer gsiSort;
30+
31+
private String stringAttribute;
32+
private Integer version;
33+
34+
public String getId() {
35+
return id;
36+
}
37+
38+
public VersionedRecord setId(String id) {
39+
this.id = id;
40+
return this;
41+
}
42+
43+
public Integer getSort() {
44+
return sort;
45+
}
46+
47+
public VersionedRecord setSort(Integer sort) {
48+
this.sort = sort;
49+
return this;
50+
}
51+
52+
public Integer getValue() {
53+
return value;
54+
}
55+
56+
public VersionedRecord setValue(Integer value) {
57+
this.value = value;
58+
return this;
59+
}
60+
61+
public String getGsiId() {
62+
return gsiId;
63+
}
64+
65+
public VersionedRecord setGsiId(String gsiId) {
66+
this.gsiId = gsiId;
67+
return this;
68+
}
69+
70+
public Integer getGsiSort() {
71+
return gsiSort;
72+
}
73+
74+
public VersionedRecord setGsiSort(Integer gsiSort) {
75+
this.gsiSort = gsiSort;
76+
return this;
77+
}
78+
79+
public String getStringAttribute() {
80+
return stringAttribute;
81+
}
82+
83+
public VersionedRecord setStringAttribute(String stringAttribute) {
84+
this.stringAttribute = stringAttribute;
85+
return this;
86+
}
87+
88+
@DynamoDbVersionAttribute
89+
public Integer getVersion() {
90+
return version;
91+
}
92+
93+
public VersionedRecord setVersion(Integer version) {
94+
this.version = version;
95+
return this;
96+
}
97+
98+
@Override
99+
public boolean equals(Object o) {
100+
if (this == o) {
101+
return true;
102+
}
103+
if (o == null || getClass() != o.getClass()) {
104+
return false;
105+
}
106+
VersionedRecord versionedRecord = (VersionedRecord) o;
107+
return Objects.equals(id, versionedRecord.id) &&
108+
Objects.equals(sort, versionedRecord.sort) &&
109+
Objects.equals(value, versionedRecord.value) &&
110+
Objects.equals(gsiId, versionedRecord.gsiId) &&
111+
Objects.equals(stringAttribute, versionedRecord.stringAttribute) &&
112+
Objects.equals(gsiSort, versionedRecord.gsiSort) &&
113+
Objects.equals(version, versionedRecord.version);
114+
}
115+
116+
@Override
117+
public int hashCode() {
118+
return Objects.hash(id, sort, value, gsiId, gsiSort, stringAttribute, version);
119+
}
120+
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
3232
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
3333
import software.amazon.awssdk.enhanced.dynamodb.Expression;
34+
import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName;
3435
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
3536
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
3637
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -64,8 +65,9 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt
6465

6566
private final long startAt;
6667
private final long incrementBy;
68+
private final boolean optimisticLockingOnDelete;
6769

68-
private VersionedRecordExtension(Long startAt, Long incrementBy) {
70+
private VersionedRecordExtension(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) {
6971
Validate.isNotNegativeOrNull(startAt, "startAt");
7072

7173
if (incrementBy != null && incrementBy < 1) {
@@ -74,6 +76,7 @@ private VersionedRecordExtension(Long startAt, Long incrementBy) {
7476

7577
this.startAt = startAt != null ? startAt : 0L;
7678
this.incrementBy = incrementBy != null ? incrementBy : 1L;
79+
this.optimisticLockingOnDelete = optimisticLockingOnDelete != null && optimisticLockingOnDelete;
7780
}
7881

7982
public static Builder builder() {
@@ -91,23 +94,37 @@ public static StaticAttributeTag versionAttribute() {
9194
public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) {
9295
return new VersionAttribute(startAt, incrementBy);
9396
}
97+
98+
public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) {
99+
return new VersionAttribute(startAt, incrementBy, optimisticLockingOnDelete);
100+
}
94101
}
95102

96103
private static final class VersionAttribute implements StaticAttributeTag {
97104
private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt";
98105
private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy";
106+
private static final String OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY = "VersionedRecordExtension:OptimisticLockingOnDelete";
99107

100108
private final Long startAt;
101109
private final Long incrementBy;
110+
private final Boolean optimisticLockingOnDelete;
102111

103112
private VersionAttribute() {
104113
this.startAt = null;
105114
this.incrementBy = null;
115+
this.optimisticLockingOnDelete = null;
106116
}
107117

108118
private VersionAttribute(Long startAt, Long incrementBy) {
109119
this.startAt = startAt;
110120
this.incrementBy = incrementBy;
121+
this.optimisticLockingOnDelete = null;
122+
}
123+
124+
private VersionAttribute(Long startAt, Long incrementBy, Boolean optimisticLockingOnDelete) {
125+
this.startAt = startAt;
126+
this.incrementBy = incrementBy;
127+
this.optimisticLockingOnDelete = optimisticLockingOnDelete;
111128
}
112129

113130
@Override
@@ -128,6 +145,7 @@ public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName
128145
return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName)
129146
.addCustomMetadataObject(START_AT_METADATA_KEY, startAt)
130147
.addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy)
148+
.addCustomMetadataObject(OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY, optimisticLockingOnDelete)
131149
.markAttributeAsKey(attributeName, attributeValueType);
132150
}
133151
}
@@ -141,6 +159,27 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
141159
return WriteModification.builder().build();
142160
}
143161

162+
// Check if optimistic locking is enabled for delete operations
163+
// First check attribute-level setting, then fall back to extension-level setting
164+
Boolean attributeLevelOptimisticLocking = context.tableMetadata()
165+
.customMetadataObject(VersionAttribute.OPTIMISTIC_LOCKING_ON_DELETE_METADATA_KEY, Boolean.class)
166+
.orElse(null);
167+
168+
boolean shouldApplyOptimisticLocking = attributeLevelOptimisticLocking != null
169+
? attributeLevelOptimisticLocking
170+
: this.optimisticLockingOnDelete;
171+
172+
// Handle DELETE operations with optimistic locking if enabled
173+
if (context.operationName() == OperationName.DELETE_ITEM && shouldApplyOptimisticLocking) {
174+
return handleOptimisticDelete(context, versionAttributeKey.get());
175+
}
176+
177+
// For non-delete operations, skip version handling if it's a delete
178+
if (context.operationName() == OperationName.DELETE_ITEM) {
179+
return WriteModification.builder().build();
180+
}
181+
182+
// Existing logic for other operations
144183
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items());
145184

146185
String attributeKeyRef = keyRef(versionAttributeKey.get());
@@ -204,6 +243,30 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
204243
.build();
205244
}
206245

246+
private WriteModification handleOptimisticDelete(DynamoDbExtensionContext.BeforeWrite context, String versionAttributeKey) {
247+
// Look for version in the items map
248+
AttributeValue versionValue = context.items().get(versionAttributeKey);
249+
250+
if (versionValue != null && versionValue.n() != null) {
251+
// Build condition for the specific version
252+
String attributeKeyRef = keyRef(versionAttributeKey);
253+
String valueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey);
254+
255+
Expression condition = Expression.builder()
256+
.expression(String.format("%s = %s", attributeKeyRef, valueKey))
257+
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey))
258+
.expressionValues(Collections.singletonMap(valueKey, versionValue))
259+
.build();
260+
261+
return WriteModification.builder()
262+
.additionalConditionalExpression(condition)
263+
.build();
264+
}
265+
266+
// If no version value is provided, don't add any condition (backward compatible)
267+
return WriteModification.builder().build();
268+
}
269+
207270
private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) {
208271
if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
209272
return true;
@@ -223,6 +286,7 @@ private boolean isInitialVersion(AttributeValue existingVersionValue, Long versi
223286
public static final class Builder {
224287
private Long startAt;
225288
private Long incrementBy;
289+
private Boolean optimisticLockingOnDelete;
226290

227291
private Builder() {
228292
}
@@ -251,8 +315,20 @@ public Builder incrementBy(Long incrementBy) {
251315
return this;
252316
}
253317

318+
/**
319+
* Enables or disables optimistic locking for delete operations. When enabled, delete operations will include a condition
320+
* to check the version attribute. Default value - {@code false} (for backward compatibility).
321+
*
322+
* @param optimisticLockingOnDelete true to enable optimistic locking on deletes, false to disable
323+
* @return the builder instance
324+
*/
325+
public Builder optimisticLockingOnDelete(Boolean optimisticLockingOnDelete) {
326+
this.optimisticLockingOnDelete = optimisticLockingOnDelete;
327+
return this;
328+
}
329+
254330
public VersionedRecordExtension build() {
255-
return new VersionedRecordExtension(this.startAt, this.incrementBy);
331+
return new VersionedRecordExtension(this.startAt, this.incrementBy, this.optimisticLockingOnDelete);
256332
}
257333
}
258334
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,11 @@
4949
*/
5050
long incrementBy() default 1;
5151

52+
/**
53+
* Whether to enable optimistic locking for delete operations. When enabled, delete operations will include a condition to
54+
* check the version attribute. Default value - {@code false} (for backward compatibility).
55+
*
56+
* @return true to enable optimistic locking on deletes, false to disable
57+
*/
58+
boolean optimisticLockingOnDelete() default false;
5259
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ public CompletableFuture<T> deleteItem(Key key) {
145145

146146
@Override
147147
public CompletableFuture<T> deleteItem(T keyItem) {
148-
return deleteItem(keyFrom(keyItem));
148+
return deleteItem(DeleteItemEnhancedRequest.builder()
149+
.key(keyFrom(keyItem))
150+
.items(tableSchema.itemToMap(keyItem, true)).build());
149151
}
150152

151153
@Override

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ public T deleteItem(Key key) {
146146

147147
@Override
148148
public T deleteItem(T keyItem) {
149-
return deleteItem(keyFrom(keyItem));
149+
return deleteItem(DeleteItemEnhancedRequest.builder()
150+
.key(keyFrom(keyItem))
151+
.items(tableSchema.itemToMap(keyItem, true)).build());
150152
}
151153

152154
@Override

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ private VersionRecordAttributeTags() {
2626
}
2727

2828
public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) {
29-
return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy());
29+
return VersionedRecordExtension.AttributeTags.versionAttribute(
30+
annotation.startAt(),
31+
annotation.incrementBy(),
32+
annotation.optimisticLockingOnDelete()
33+
);
3034
}
3135
}

0 commit comments

Comments
 (0)