Skip to content

Commit 8bd1d2e

Browse files
authored
Ddb versioned record extension fix (#6659)
* Add support for handling existing records with version 0 (#6643) Add support for handling existing records with version 0 * added ability to set startAt to -1 to allow version 0 (#6653) added ability to set startAt to -1 to allow versioning to begin from 0 * Add changelog * Fix changelog and add coverage * Fix changelog
1 parent 0e0d18f commit 8bd1d2e

6 files changed

Lines changed: 413 additions & 34 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "DynamoDB Enhanced Client",
4+
"contributor": "",
5+
"description": "Allow new records to be initialized with version=0 by supporting startAt=-1 in VersionedRecordExtension"
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "feature",
3+
"category": "DynamoDB Enhanced Client",
4+
"contributor": "",
5+
"description": "modify VersionedRecordExtension to support updating existing records with version=0 using OR condition"
6+
}

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

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
3636
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
3737
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
38-
import software.amazon.awssdk.utils.Validate;
3938

4039
/**
4140
* This extension implements optimistic locking on record writes by means of a 'record version number' that is used
@@ -55,6 +54,10 @@
5554
* Then, whenever a record is written the write operation will only succeed if the version number of the record has not
5655
* been modified since it was last read by the application. Every time a new version of the record is successfully
5756
* written to the database, the record version number will be automatically incremented.
57+
* <p>
58+
* <b>Version Calculation:</b> The first version written to a new record is calculated as {@code startAt + incrementBy}.
59+
* For example, with {@code startAt=0} and {@code incrementBy=1} (defaults), the first version is 1.
60+
* To start versioning from 0, use {@code startAt=-1} and {@code incrementBy=1}, which produces first version = 0.
5861
*/
5962
@SdkPublicApi
6063
@ThreadSafe
@@ -68,7 +71,9 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt
6871
private final long incrementBy;
6972

7073
private VersionedRecordExtension(Long startAt, Long incrementBy) {
71-
Validate.isNotNegativeOrNull(startAt, "startAt");
74+
if (startAt != null && startAt < -1) {
75+
throw new IllegalArgumentException("startAt must be -1 or greater");
76+
}
7277

7378
if (incrementBy != null && incrementBy < 1) {
7479
throw new IllegalArgumentException("incrementBy must be greater than 0.");
@@ -121,7 +126,9 @@ public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName
121126
"is supported.", attributeName, attributeValueType.name()));
122127
}
123128

124-
Validate.isNotNegativeOrNull(startAt, "startAt");
129+
if (startAt != null && startAt < -1) {
130+
throw new IllegalArgumentException("startAt must be -1 or greater.");
131+
}
125132

126133
if (incrementBy != null && incrementBy < 1) {
127134
throw new IllegalArgumentException("incrementBy must be greater than 0.");
@@ -158,7 +165,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
158165
.orElse(this.incrementBy);
159166

160167

161-
if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) {
168+
if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
162169
newVersionValue = AttributeValue.builder()
163170
.n(Long.toString(versionStartAtFromAnnotation + versionIncrementByFromAnnotation))
164171
.build();
@@ -175,7 +182,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
175182

176183
long existingVersion = Long.parseLong(existingVersionValue.n());
177184
String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get());
178-
179185
long increment = versionIncrementByFromAnnotation;
180186

181187
/*
@@ -190,12 +196,25 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
190196

191197
newVersionValue = AttributeValue.builder().n(Long.toString(existingVersion + increment)).build();
192198

193-
condition = Expression.builder()
194-
.expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey))
195-
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get()))
196-
.expressionValues(Collections.singletonMap(existingVersionValueKey,
197-
existingVersionValue))
198-
.build();
199+
// When version equals startAt, we can't distinguish between new and existing records
200+
// Use OR condition to handle both cases
201+
if (existingVersion == versionStartAtFromAnnotation) {
202+
condition = Expression.builder()
203+
.expression(String.format("attribute_not_exists(%s) OR %s = %s",
204+
attributeKeyRef, attributeKeyRef, existingVersionValueKey))
205+
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get()))
206+
.expressionValues(Collections.singletonMap(existingVersionValueKey,
207+
existingVersionValue))
208+
.build();
209+
} else {
210+
// Normal case - version doesn't equal startAt, must be existing record
211+
condition = Expression.builder()
212+
.expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey))
213+
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get()))
214+
.expressionValues(Collections.singletonMap(existingVersionValueKey,
215+
existingVersionValue))
216+
.build();
217+
}
199218
}
200219

201220
itemToTransform.put(versionAttributeKey.get(), newVersionValue);
@@ -206,21 +225,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
206225
.build();
207226
}
208227

209-
private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) {
210-
if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
211-
return true;
212-
}
213-
214-
if (existingVersionValue.n() != null) {
215-
long currentVersion = Long.parseLong(existingVersionValue.n());
216-
// If annotation value is present, use it, otherwise fall back to the extension's value
217-
Long effectiveStartAt = versionStartAtFromAnnotation != null ? versionStartAtFromAnnotation : this.startAt;
218-
return currentVersion == effectiveStartAt;
219-
}
220-
221-
return false;
222-
}
223-
224228
@NotThreadSafe
225229
public static final class Builder {
226230
private Long startAt;
@@ -231,9 +235,10 @@ private Builder() {
231235

232236
/**
233237
* Sets the startAt used to compare if a record is the initial version of a record.
238+
* The first version written to a new record is calculated as {@code startAt + incrementBy}.
234239
* Default value - {@code 0}.
235240
*
236-
* @param startAt the starting value for version comparison, must not be negative
241+
* @param startAt the starting value for version comparison, must be -1 or greater
237242
* @return the builder instance
238243
*/
239244
public Builder startAt(Long startAt) {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
* Denotes this attribute as recording the version record number to be used for optimistic locking. Every time a record
2828
* with this attribute is written to the database it will be incremented and a condition added to the request to check
2929
* for an exact match of the old version.
30+
* <p>
31+
* <b>Version Calculation:</b> The first version written to a new record is calculated as {@code startAt + incrementBy}.
32+
* For example, with {@code startAt=0} and {@code incrementBy=1} (defaults), the first version is 1.
33+
* To start versioning from 0, use {@code startAt=-1} and {@code incrementBy=1}, which produces first version = 0.
3034
*/
3135
@SdkPublicApi
3236
@Target({ElementType.METHOD})

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ public void beforeWrite_versionEqualsStartAt_treatedAsInitialVersion() {
212212
.operationContext(PRIMARY_CONTEXT).build());
213213

214214
assertThat(result.additionalConditionalExpression().expression(),
215-
is("attribute_not_exists(#AMZN_MAPPED_version)"));
215+
is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
216216
}
217217

218218
@ParameterizedTest
@@ -321,7 +321,7 @@ public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion
321321
.operationContext(PRIMARY_CONTEXT).build());
322322

323323
assertThat(result.additionalConditionalExpression().expression(),
324-
is("attribute_not_exists(#AMZN_MAPPED_version)"));
324+
is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
325325
}
326326

327327

@@ -634,6 +634,46 @@ public void isInitialVersion_shouldPrioritizeAnnotationValueOverBuilderValue() {
634634
is("#AMZN_MAPPED_version = :old_version_value"));
635635
}
636636

637+
@Test
638+
public void updateItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() {
639+
VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build();
640+
FakeItem item = createUniqueFakeItem();
641+
item.setVersion(0);
642+
643+
Map<String, AttributeValue> inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(item, true));
644+
645+
WriteModification result =
646+
recordExtension.beforeWrite(DefaultDynamoDbExtensionContext
647+
.builder()
648+
.items(inputMap)
649+
.tableMetadata(FakeItem.getTableMetadata())
650+
.operationContext(PRIMARY_CONTEXT).build());
651+
652+
assertThat(result.additionalConditionalExpression().expression(),
653+
is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
654+
}
655+
656+
@Test
657+
public void beforeWrite_startAtNegativeOne_firstVersionIsZero() {
658+
VersionedRecordExtension extension = VersionedRecordExtension.builder()
659+
.startAt(-1L)
660+
.incrementBy(1L)
661+
.build();
662+
FakeItem fakeItem = createUniqueFakeItem();
663+
Map<String, AttributeValue> expectedItem =
664+
new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true));
665+
expectedItem.put("version", AttributeValue.builder().n("0").build());
666+
667+
WriteModification result =
668+
extension.beforeWrite(DefaultDynamoDbExtensionContext
669+
.builder()
670+
.items(FakeItem.getTableSchema().itemToMap(fakeItem, true))
671+
.tableMetadata(FakeItem.getTableMetadata())
672+
.operationContext(PRIMARY_CONTEXT).build());
673+
674+
assertThat(result.transformedItem(), is(expectedItem));
675+
}
676+
637677
public static Stream<Arguments> customIncrementForExistingVersionValues() {
638678
return Stream.of(
639679
Arguments.of(0L, 1L, 5L, "6"),

0 commit comments

Comments
 (0)