Skip to content

Commit e648519

Browse files
authored
DDB Enhanced: Allow custom versioning (#6019)
* Adding annotation support * Making annotation values nullable to know if initial value is explicitly set * Added changelog with attribution * Fix checkstyle * Adding japicmp excludes block * Adding additional test coverage to account for all isInitialVersion codepaths * Removing logger config file * porting version from Integer to Long, removing refactoring * refactoring and adding test coverage * adding annotation test * adding localstack test, removing optional from function signature * fix checkstyle * Adding overflow protection * Fixed edge case in isInitialVersion, fixed javadoc and typos --------- Co-authored-by: Ran Vaknin <[email protected]>
1 parent db47c34 commit e648519

File tree

10 files changed

+921
-21
lines changed

10 files changed

+921
-21
lines changed
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": "akiesler",
5+
"description": "Support for Version Starting at 0 with Configurable Increment"
6+
}

pom.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,9 @@
683683
<includeModule>polly</includeModule>
684684
</includeModules>
685685
<excludes>
686+
<!-- TODO remove after release -->
687+
<exclude>software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#incrementBy()</exclude>
688+
<exclude>software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#startAt()</exclude>
686689
<exclude>*.internal.*</exclude>
687690
<exclude>software.amazon.awssdk.thirdparty.*</exclude>
688691
<exclude>software.amazon.awssdk.regions.*</exclude>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import software.amazon.awssdk.annotations.SdkPublicApi;
2727
import software.amazon.awssdk.annotations.ThreadSafe;
2828
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
29+
import software.amazon.awssdk.utils.ToString;
2930

3031
/**
3132
* High-level representation of a DynamoDB 'expression' that can be used in various situations where the API requires
@@ -311,6 +312,15 @@ public int hashCode() {
311312
return result;
312313
}
313314

315+
@Override
316+
public String toString() {
317+
return ToString.builder("Expression")
318+
.add("expression", expression)
319+
.add("expressionValues", expressionValues)
320+
.add("expressionNames", expressionNames)
321+
.build();
322+
}
323+
314324
/**
315325
* A builder for {@link Expression}
316326
*/

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

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
3535
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
3636
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
37+
import software.amazon.awssdk.utils.Validate;
3738

3839
/**
3940
* This extension implements optimistic locking on record writes by means of a 'record version number' that is used
@@ -61,7 +62,18 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt
6162
private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute";
6263
private static final VersionAttribute VERSION_ATTRIBUTE = new VersionAttribute();
6364

64-
private VersionedRecordExtension() {
65+
private final long startAt;
66+
private final long incrementBy;
67+
68+
private VersionedRecordExtension(Long startAt, Long incrementBy) {
69+
Validate.isNotNegativeOrNull(startAt, "startAt");
70+
71+
if (incrementBy != null && incrementBy < 1) {
72+
throw new IllegalArgumentException("incrementBy must be greater than 0.");
73+
}
74+
75+
this.startAt = startAt != null ? startAt : 0L;
76+
this.incrementBy = incrementBy != null ? incrementBy : 1L;
6577
}
6678

6779
public static Builder builder() {
@@ -75,19 +87,47 @@ private AttributeTags() {
7587
public static StaticAttributeTag versionAttribute() {
7688
return VERSION_ATTRIBUTE;
7789
}
90+
91+
public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) {
92+
return new VersionAttribute(startAt, incrementBy);
93+
}
7894
}
7995

80-
private static class VersionAttribute implements StaticAttributeTag {
96+
private static final class VersionAttribute implements StaticAttributeTag {
97+
private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt";
98+
private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy";
99+
100+
private final Long startAt;
101+
private final Long incrementBy;
102+
103+
private VersionAttribute() {
104+
this.startAt = null;
105+
this.incrementBy = null;
106+
}
107+
108+
private VersionAttribute(Long startAt, Long incrementBy) {
109+
this.startAt = startAt;
110+
this.incrementBy = incrementBy;
111+
}
112+
81113
@Override
82114
public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName,
83115
AttributeValueType attributeValueType) {
84116
if (attributeValueType != AttributeValueType.N) {
85117
throw new IllegalArgumentException(String.format(
86118
"Attribute '%s' of type %s is not a suitable type to be used as a version attribute. Only type 'N' " +
87-
"is supported.", attributeName, attributeValueType.name()));
119+
"is supported.", attributeName, attributeValueType.name()));
120+
}
121+
122+
Validate.isNotNegativeOrNull(startAt, "startAt");
123+
124+
if (incrementBy != null && incrementBy < 1) {
125+
throw new IllegalArgumentException("incrementBy must be greater than 0.");
88126
}
89127

90128
return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName)
129+
.addCustomMetadataObject(START_AT_METADATA_KEY, startAt)
130+
.addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy)
91131
.markAttributeAsKey(attributeName, attributeValueType);
92132
}
93133
}
@@ -106,31 +146,53 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
106146
String attributeKeyRef = keyRef(versionAttributeKey.get());
107147
AttributeValue newVersionValue;
108148
Expression condition;
109-
Optional<AttributeValue> existingVersionValue =
110-
Optional.ofNullable(itemToTransform.get(versionAttributeKey.get()));
111149

112-
if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get())) {
113-
// First version of the record
114-
newVersionValue = AttributeValue.builder().n("1").build();
150+
AttributeValue existingVersionValue = itemToTransform.get(versionAttributeKey.get());
151+
Long versionStartAtFromAnnotation = context.tableMetadata()
152+
.customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, Long.class)
153+
.orElse(this.startAt);
154+
Long versionIncrementByFromAnnotation = context.tableMetadata()
155+
.customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, Long.class)
156+
.orElse(this.incrementBy);
157+
158+
159+
if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) {
160+
newVersionValue = AttributeValue.builder()
161+
.n(Long.toString(versionStartAtFromAnnotation + versionIncrementByFromAnnotation))
162+
.build();
115163
condition = Expression.builder()
116164
.expression(String.format("attribute_not_exists(%s)", attributeKeyRef))
117165
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get()))
118166
.build();
119167
} else {
120168
// Existing record, increment version
121-
if (existingVersionValue.get().n() == null) {
169+
if (existingVersionValue.n() == null) {
122170
// In this case a non-null version attribute is present, but it's not an N
123171
throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required.");
124172
}
125173

126-
int existingVersion = Integer.parseInt(existingVersionValue.get().n());
174+
long existingVersion = Long.parseLong(existingVersionValue.n());
127175
String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get());
128-
newVersionValue = AttributeValue.builder().n(Integer.toString(existingVersion + 1)).build();
176+
177+
long increment = versionIncrementByFromAnnotation;
178+
179+
/*
180+
Since the new incrementBy and StartAt functionality can now accept any positive number, though unlikely
181+
to happen in a real life scenario, we should add overflow protection.
182+
*/
183+
if (existingVersion > Long.MAX_VALUE - increment) {
184+
throw new IllegalStateException(
185+
String.format("Version overflow detected. Current version %d + increment %d would exceed Long.MAX_VALUE",
186+
existingVersion, increment));
187+
}
188+
189+
newVersionValue = AttributeValue.builder().n(Long.toString(existingVersion + increment)).build();
190+
129191
condition = Expression.builder()
130192
.expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey))
131193
.expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get()))
132194
.expressionValues(Collections.singletonMap(existingVersionValueKey,
133-
existingVersionValue.get()))
195+
existingVersionValue))
134196
.build();
135197
}
136198

@@ -142,13 +204,55 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
142204
.build();
143205
}
144206

207+
private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) {
208+
if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
209+
return true;
210+
}
211+
212+
if (existingVersionValue.n() != null) {
213+
long currentVersion = Long.parseLong(existingVersionValue.n());
214+
// If annotation value is present, use it, otherwise fall back to the extension's value
215+
Long effectiveStartAt = versionStartAtFromAnnotation != null ? versionStartAtFromAnnotation : this.startAt;
216+
return currentVersion == effectiveStartAt;
217+
}
218+
219+
return false;
220+
}
221+
145222
@NotThreadSafe
146223
public static final class Builder {
224+
private Long startAt;
225+
private Long incrementBy;
226+
147227
private Builder() {
148228
}
149229

230+
/**
231+
* Sets the startAt used to compare if a record is the initial version of a record.
232+
* Default value - {@code 0}.
233+
*
234+
* @param startAt the starting value for version comparison, must not be negative
235+
* @return the builder instance
236+
*/
237+
public Builder startAt(Long startAt) {
238+
this.startAt = startAt;
239+
return this;
240+
}
241+
242+
/**
243+
* Sets the amount to increment the version by with each subsequent update.
244+
* Default value - {@code 1}.
245+
*
246+
* @param incrementBy the amount to increment the version by, must be greater than 0
247+
* @return the builder instance
248+
*/
249+
public Builder incrementBy(Long incrementBy) {
250+
this.incrementBy = incrementBy;
251+
return this;
252+
}
253+
150254
public VersionedRecordExtension build() {
151-
return new VersionedRecordExtension();
255+
return new VersionedRecordExtension(this.startAt, this.incrementBy);
152256
}
153257
}
154258
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,20 @@
3333
@Retention(RetentionPolicy.RUNTIME)
3434
@BeanTableSchemaAttributeTag(VersionRecordAttributeTags.class)
3535
public @interface DynamoDbVersionAttribute {
36+
/**
37+
* The starting value for the version attribute.
38+
* Default value - {@code 0}.
39+
*
40+
* @return the starting value
41+
*/
42+
long startAt() default 0;
43+
44+
/**
45+
* The amount to increment the version by with each update.
46+
* Default value - {@code 1}.
47+
*
48+
* @return the increment value
49+
*/
50+
long incrementBy() default 1;
51+
3652
}

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

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

2828
public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) {
29-
return VersionedRecordExtension.AttributeTags.versionAttribute();
29+
return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy());
3030
}
3131
}

0 commit comments

Comments
 (0)