Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.utils.Validate;

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

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

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

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

if (incrementBy != null && incrementBy < 1) {
throw new IllegalArgumentException("incrementBy must be greater than 0.");
Expand Down Expand Up @@ -228,9 +235,10 @@ private Builder() {

/**
* Sets the startAt used to compare if a record is the initial version of a record.
* The first version written to a new record is calculated as {@code startAt + incrementBy}.
* Default value - {@code 0}.
*
* @param startAt the starting value for version comparison, must not be negative
* @param startAt the starting value for version comparison, must be -1 or greater
* @return the builder instance
*/
public Builder startAt(Long startAt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
* Denotes this attribute as recording the version record number to be used for optimistic locking. Every time a record
* with this attribute is written to the database it will be incremented and a condition added to the request to check
* for an exact match of the old version.
* <p>
* <b>Version Calculation:</b> The first version written to a new record is calculated as {@code startAt + incrementBy}.
* For example, with {@code startAt=0} and {@code incrementBy=1} (defaults), the first version is 1.
* To start versioning from 0, use {@code startAt=-1} and {@code incrementBy=1}, which produces first version = 0.
*/
@SdkPublicApi
@Target({ElementType.METHOD})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,26 @@ public void updateItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() {
is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
}

@Test
public void beforeWrite_startAtNegativeOne_firstVersionIsZero() {
VersionedRecordExtension extension = VersionedRecordExtension.builder()
.startAt(-1L)
.incrementBy(1L)
.build();
FakeItem fakeItem = createUniqueFakeItem();
Map<String, AttributeValue> expectedItem =
new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true));
expectedItem.put("version", AttributeValue.builder().n("0").build());

WriteModification result =
extension.beforeWrite(DefaultDynamoDbExtensionContext
.builder()
.items(FakeItem.getTableSchema().itemToMap(fakeItem, true))
.tableMetadata(FakeItem.getTableMetadata())
.operationContext(PRIMARY_CONTEXT).build());

assertThat(result.transformedItem(), is(expectedItem));
}

public static Stream<Arguments> customIncrementForExistingVersionValues() {
return Stream.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ public AnnotatedRecord setAttribute(String attribute) {
}
}

@DynamoDbBean
public static class AnnotatedRecordStartAtNegativeOne {
private String id;
private String attribute;
private Long version;

@DynamoDbPartitionKey
public String getId() { return id; }
public void setId(String id) { this.id = id; }

@DynamoDbVersionAttribute(startAt = -1, incrementBy = 1)
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }

public String getAttribute() { return attribute; }
public void setAttribute(String attribute) { this.attribute = attribute; }
}

private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(getDynamoDbClient())
.extensions(VersionedRecordExtension.builder().build())
Expand All @@ -158,18 +176,37 @@ public AnnotatedRecord setAttribute(String attribute) {
)
.build();

private DynamoDbEnhancedClient startAtNegativeOneClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(getDynamoDbClient())
.extensions(VersionedRecordExtension
.builder()
.startAt(-1L)
.incrementBy(1L)
.build()
)
.build();

private DynamoDbTable<Record> mappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA);

private DynamoDbTable<Record> mappedCustomVersionedTable = customVersionedEnhancedClient
.table(getConcreteTableName("table-name2"), TABLE_SCHEMA);

private DynamoDbTable<Record> startAtNegativeOneTable = startAtNegativeOneClient
.table(getConcreteTableName("startAt-neg-one-table"), TABLE_SCHEMA);


private static final TableSchema<AnnotatedRecord> ANNOTATED_TABLE_SCHEMA =
TableSchema.fromBean(AnnotatedRecord.class);

private DynamoDbTable<AnnotatedRecord> annotatedTable = enhancedClient
.table(getConcreteTableName("annotated-table"), ANNOTATED_TABLE_SCHEMA);

private static final TableSchema<AnnotatedRecordStartAtNegativeOne> ANNOTATED_START_AT_NEG_ONE_SCHEMA =
TableSchema.fromBean(AnnotatedRecordStartAtNegativeOne.class);

private DynamoDbTable<AnnotatedRecordStartAtNegativeOne> annotatedStartAtNegativeOneTable = enhancedClient
.table(getConcreteTableName("annotated-startAt-neg-one-table"), ANNOTATED_START_AT_NEG_ONE_SCHEMA);




Expand All @@ -181,6 +218,8 @@ public void createTable() {
mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
mappedCustomVersionedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
annotatedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
startAtNegativeOneTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
annotatedStartAtNegativeOneTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
}

@After
Expand All @@ -196,6 +235,14 @@ public void deleteTable() {
getDynamoDbClient().deleteTable(DeleteTableRequest.builder()
.tableName(getConcreteTableName("annotated-table"))
.build());

getDynamoDbClient().deleteTable(DeleteTableRequest.builder()
.tableName(getConcreteTableName("startAt-neg-one-table"))
.build());

getDynamoDbClient().deleteTable(DeleteTableRequest.builder()
.tableName(getConcreteTableName("annotated-startAt-neg-one-table"))
.build());
}


Expand Down Expand Up @@ -580,4 +627,50 @@ public void deleteItem_annotationConfigWithVersionEqualToStartAt_shouldSucceed()
AnnotatedRecord shouldBeNull = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("delete-annotation")));
assertThat(shouldBeNull, is(nullValue()));
}

@Test
public void putItem_startAtNegativeOne_firstVersionIsZero() {
startAtNegativeOneTable.putItem(r -> r.item(new Record().setId("test-id").setAttribute("value")));

Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id")));
assertThat(result.getVersion(), is(0));
}

@Test
public void updateItem_startAtNegativeOne_incrementsFromZero() {
startAtNegativeOneTable.putItem(r -> r.item(new Record().setId("test-id-2").setAttribute("value")));

Record recordToUpdate = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2")));
recordToUpdate.setAttribute("updated");
startAtNegativeOneTable.updateItem(r -> r.item(recordToUpdate));

Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2")));
assertThat(result.getVersion(), is(1));
}

@Test
public void updateItem_startAtNegativeOne_versionMatchesStartAt_shouldSucceed() {
Map<String, AttributeValue> item = new HashMap<>();
item.put("id", stringValue("test-id-3"));
item.put("attribute", stringValue("value"));
item.put("version", AttributeValue.builder().n("-1").build());
getDynamoDbClient().putItem(r -> r.tableName(startAtNegativeOneTable.tableName()).item(item));

startAtNegativeOneTable.updateItem(r -> r.item(new Record().setId("test-id-3").setAttribute("updated").setVersion(-1)));

Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-3")));
assertThat(result.getVersion(), is(0));
}

@Test
public void annotatedRecord_startAtNegativeOne_firstVersionIsZero() {
AnnotatedRecordStartAtNegativeOne record = new AnnotatedRecordStartAtNegativeOne();
record.setId("test-id");
record.setAttribute("value");

annotatedStartAtNegativeOneTable.putItem(record);

AnnotatedRecordStartAtNegativeOne result = annotatedStartAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id")));
assertThat(result.getVersion(), is(0L));
}
}