Skip to content

Commit d7561dd

Browse files
authored
Merge pull request #24 from JupiterOne/core-1836-optimistic-locking
CORE-1836 Optimistic locking with version number
2 parents 6a639da + 332e251 commit d7561dd

10 files changed

+626
-21
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ and this project adheres to
1313
### Added
1414

1515
- Support `consistentRead` option on `get` API
16+
17+
## 1.5.0 - 2021-10-27
18+
19+
### Added
20+
21+
- Support optimistic locking for `put`, `update` and `delete` APIs

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,43 @@ const { total } = await myDocumentDao.decr(
137137
);
138138
```
139139

140+
**Optimistic Locking with Version Numbers**
141+
142+
For callers who wish to enable an optimistic locking strategy there are two
143+
available toggles:
144+
145+
1. Provide the attribute you wish to be used to store the version number. This
146+
will enable optimistic locking on the following operations: `put`, `update`,
147+
and `delete`.
148+
149+
Writes for documents that do not have a version number attribute will
150+
initialize the version number to 1. All subsequent writes will need to
151+
provide the current version number. If an out-of-date version number is
152+
supplied, an error will be thrown.
153+
154+
Example of Dao constructed with optimistic locking enabled.
155+
156+
```
157+
const dao = new DynamoDbDao<DataModel, KeySchema>({
158+
tableName,
159+
documentClient,
160+
optimisticLockingAttribute: 'version',
161+
});
162+
```
163+
164+
2. If you wish to ignore optimistic locking for a save operation, specify
165+
`ignoreOptimisticLocking: true` in the options on your `put`, `update`, or
166+
`delete`.
167+
168+
NOTE: Optimistic locking is NOT supported for `batchWrite` or `batchPut`
169+
operations. Consuming those APIs for data models that do have optimistic locking
170+
enabled may clobber your version data and could produce undesirable effects for
171+
other callers.
172+
173+
This was modeled after the
174+
[Java Dynamo client](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBMapper.OptimisticLocking.html)
175+
implementation.
176+
140177
## Developing
141178

142179
The test setup requires that [docker-compose]() be installed. To run the tests,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jupiterone/dynamodb-dao",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "DynamoDB Data Access Object (DAO) helper library",
55
"main": "index.js",
66
"types": "index.d.ts",

src/index.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,82 @@ test('#generateUpdateParams should generate both update and remove params for do
559559
}
560560
});
561561

562+
test('#generateUpdateParams should increment the version number', () => {
563+
{
564+
const options = {
565+
tableName: 'blah2',
566+
key: {
567+
HashKey: 'abc',
568+
},
569+
data: {
570+
a: 123,
571+
b: 'abc',
572+
c: undefined,
573+
lockVersion: 1,
574+
},
575+
optimisticLockVersionAttribute: 'lockVersion',
576+
};
577+
578+
expect(generateUpdateParams(options)).toEqual({
579+
TableName: options.tableName,
580+
Key: options.key,
581+
ReturnValues: 'ALL_NEW',
582+
ConditionExpression: '#lockVersion = :lockVersion',
583+
UpdateExpression:
584+
'add #lockVersion :lockVersionInc set #a0 = :a0, #a1 = :a1 remove #a2',
585+
ExpressionAttributeNames: {
586+
'#a0': 'a',
587+
'#a1': 'b',
588+
'#a2': 'c',
589+
'#lockVersion': 'lockVersion',
590+
},
591+
ExpressionAttributeValues: {
592+
':a0': options.data.a,
593+
':a1': options.data.b,
594+
':lockVersionInc': 1,
595+
':lockVersion': 1,
596+
},
597+
});
598+
}
599+
});
600+
601+
test('#generateUpdateParams should increment the version number even when not supplied', () => {
602+
{
603+
const options = {
604+
tableName: 'blah3',
605+
key: {
606+
HashKey: 'abc',
607+
},
608+
data: {
609+
a: 123,
610+
b: 'abc',
611+
c: undefined,
612+
},
613+
optimisticLockVersionAttribute: 'lockVersion',
614+
};
615+
616+
expect(generateUpdateParams(options)).toEqual({
617+
TableName: options.tableName,
618+
Key: options.key,
619+
ReturnValues: 'ALL_NEW',
620+
UpdateExpression:
621+
'add #lockVersion :lockVersionInc set #a0 = :a0, #a1 = :a1 remove #a2',
622+
ConditionExpression: 'attribute_not_exists(lockVersion)',
623+
ExpressionAttributeNames: {
624+
'#a0': 'a',
625+
'#a1': 'b',
626+
'#a2': 'c',
627+
'#lockVersion': 'lockVersion',
628+
},
629+
ExpressionAttributeValues: {
630+
':a0': options.data.a,
631+
':a1': options.data.b,
632+
':lockVersionInc': 1,
633+
},
634+
});
635+
}
636+
});
637+
562638
test(`#queryUntilLimitReached should call #query if "filterExpression" not provided`, async () => {
563639
const keyConditionExpression = 'id = :id';
564640
const attributeValues = { id: uuid() };

src/index.ts

Lines changed: 134 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,57 @@ export interface ConditionalOptions {
120120
attributeValues?: AttributeValues;
121121
}
122122

123-
export type PutOptions = ConditionalOptions;
124-
export type UpdateOptions = ConditionalOptions;
125-
export type DeleteOptions = ConditionalOptions;
123+
export interface SaveBehavior {
124+
optimisticLockVersionAttribute?: string;
125+
optimisticLockVersionIncrement?: number;
126+
}
127+
128+
export interface MutateBehavior {
129+
ignoreOptimisticLocking?: boolean;
130+
}
131+
132+
export type PutOptions = ConditionalOptions & MutateBehavior;
133+
export type UpdateOptions = ConditionalOptions & MutateBehavior;
134+
export type DeleteOptions = ConditionalOptions & MutateBehavior;
135+
136+
export interface BuildOptimisticLockOptionsInput extends ConditionalOptions {
137+
versionAttribute: string;
138+
versionAttributeValue: any;
139+
}
140+
141+
export function buildOptimisticLockOptions(
142+
options: BuildOptimisticLockOptionsInput
143+
): ConditionalOptions {
144+
const { versionAttribute, versionAttributeValue } = options;
145+
let { conditionExpression, attributeNames, attributeValues } = options;
146+
147+
const lockExpression = versionAttributeValue
148+
? `#${versionAttribute} = :${versionAttribute}`
149+
: `attribute_not_exists(${versionAttribute})`;
150+
151+
conditionExpression = conditionExpression
152+
? `(${conditionExpression}) AND ${lockExpression}`
153+
: lockExpression;
154+
155+
if (versionAttributeValue) {
156+
attributeNames = {
157+
...attributeNames,
158+
[`#${versionAttribute}`]: versionAttribute,
159+
};
160+
attributeValues = {
161+
...attributeValues,
162+
[`:${versionAttribute}`]: versionAttributeValue,
163+
};
164+
}
165+
166+
return {
167+
conditionExpression,
168+
attributeNames,
169+
attributeValues,
170+
};
171+
}
172+
173+
type DataModelAsMap = { [key: string]: any };
126174

127175
export interface GenerateUpdateParamsInput extends UpdateOptions {
128176
tableName: string;
@@ -131,9 +179,10 @@ export interface GenerateUpdateParamsInput extends UpdateOptions {
131179
}
132180

133181
export function generateUpdateParams(
134-
options: GenerateUpdateParamsInput
182+
options: GenerateUpdateParamsInput & SaveBehavior
135183
): DocumentClient.UpdateItemInput {
136184
const setExpressions: string[] = [];
185+
const addExpressions: string[] = [];
137186
const removeExpressions: string[] = [];
138187
const expressionAttributeNameMap: AttributeNames = {};
139188
const expressionAttributeValueMap: AttributeValues = {};
@@ -142,15 +191,41 @@ export function generateUpdateParams(
142191
tableName,
143192
key,
144193
data,
145-
conditionExpression,
146194
attributeNames,
147195
attributeValues,
196+
optimisticLockVersionAttribute: versionAttribute,
197+
optimisticLockVersionIncrement: versionInc,
198+
ignoreOptimisticLocking: ignoreLocking = false,
148199
} = options;
149200

201+
let conditionExpression = options.conditionExpression;
202+
203+
if (versionAttribute) {
204+
addExpressions.push(`#${versionAttribute} :${versionAttribute}Inc`);
205+
expressionAttributeNameMap[`#${versionAttribute}`] = versionAttribute;
206+
expressionAttributeValueMap[`:${versionAttribute}Inc`] = versionInc ?? 1;
207+
208+
if (!ignoreLocking) {
209+
({ conditionExpression } = buildOptimisticLockOptions({
210+
versionAttribute,
211+
versionAttributeValue: (data as DataModelAsMap)[versionAttribute],
212+
conditionExpression,
213+
}));
214+
expressionAttributeValueMap[`:${versionAttribute}`] = (
215+
data as DataModelAsMap
216+
)[versionAttribute];
217+
}
218+
}
219+
150220
const keys = Object.keys(options.data).sort();
151221

152222
for (let i = 0; i < keys.length; i++) {
153223
const name = keys[i];
224+
if (name === versionAttribute) {
225+
// versionAttribute is a special case and should always be handled
226+
// explicitly as above with the supplied value ignored
227+
continue;
228+
}
154229

155230
const valueName = `:a${i}`;
156231
const attributeName = `#a${i}`;
@@ -178,11 +253,13 @@ export function generateUpdateParams(
178253
? 'remove ' + removeExpressions.join(', ')
179254
: undefined;
180255

256+
const addString =
257+
addExpressions.length > 0 ? 'add ' + addExpressions.join(', ') : undefined;
181258
return {
182259
TableName: tableName,
183260
Key: key,
184261
ConditionExpression: conditionExpression,
185-
UpdateExpression: [setString, removeString]
262+
UpdateExpression: [addString, setString, removeString]
186263
.filter((val) => val !== undefined)
187264
.join(' '),
188265
ExpressionAttributeNames: {
@@ -197,9 +274,10 @@ export function generateUpdateParams(
197274
};
198275
}
199276

200-
interface DynamoDbDaoInput {
277+
export interface DynamoDbDaoInput<T> {
201278
tableName: string;
202279
documentClient: DocumentClient;
280+
optimisticLockingAttribute?: keyof NumberPropertiesInType<T>;
203281
}
204282

205283
function invalidCursorError(cursor: string): Error {
@@ -261,10 +339,12 @@ export type NumberPropertiesInType<T> = Pick<
261339
export default class DynamoDbDao<DataModel, KeySchema> {
262340
public readonly tableName: string;
263341
public readonly documentClient: DocumentClient;
342+
public readonly optimisticLockingAttribute?: keyof NumberPropertiesInType<DataModel>;
264343

265-
constructor(options: DynamoDbDaoInput) {
344+
constructor(options: DynamoDbDaoInput<DataModel>) {
266345
this.tableName = options.tableName;
267346
this.documentClient = options.documentClient;
347+
this.optimisticLockingAttribute = options.optimisticLockingAttribute;
268348
}
269349

270350
/**
@@ -292,16 +372,30 @@ export default class DynamoDbDao<DataModel, KeySchema> {
292372
*/
293373
async delete(
294374
key: KeySchema,
295-
options: DeleteOptions = {}
375+
options: DeleteOptions = {},
376+
data: Partial<DataModel> = {}
296377
): Promise<DataModel | undefined> {
378+
let { attributeNames, attributeValues, conditionExpression } = options;
379+
380+
if (this.optimisticLockingAttribute && !options.ignoreOptimisticLocking) {
381+
const versionAttribute = this.optimisticLockingAttribute.toString();
382+
({ attributeNames, attributeValues, conditionExpression } =
383+
buildOptimisticLockOptions({
384+
versionAttribute,
385+
versionAttributeValue: (data as DataModelAsMap)[versionAttribute],
386+
conditionExpression: conditionExpression,
387+
attributeNames,
388+
attributeValues,
389+
}));
390+
}
297391
const { Attributes: attributes } = await this.documentClient
298392
.delete({
299393
TableName: this.tableName,
300394
Key: key,
301395
ReturnValues: 'ALL_OLD',
302-
ConditionExpression: options.conditionExpression,
303-
ExpressionAttributeNames: options.attributeNames,
304-
ExpressionAttributeValues: options.attributeValues,
396+
ConditionExpression: conditionExpression,
397+
ExpressionAttributeNames: attributeNames,
398+
ExpressionAttributeValues: attributeValues,
305399
})
306400
.promise();
307401

@@ -312,13 +406,36 @@ export default class DynamoDbDao<DataModel, KeySchema> {
312406
* Creates/Updates an item in the table
313407
*/
314408
async put(data: DataModel, options: PutOptions = {}): Promise<DataModel> {
409+
let { conditionExpression, attributeNames, attributeValues } = options;
410+
if (this.optimisticLockingAttribute) {
411+
// Must cast data to avoid tripping the linter, otherwise, it'll complain
412+
// about expression of type 'string' can't be used to index type 'unknown'
413+
const dataAsMap = data as DataModelAsMap;
414+
const versionAttribute = this.optimisticLockingAttribute.toString();
415+
416+
if (!options.ignoreOptimisticLocking) {
417+
({ conditionExpression, attributeNames, attributeValues } =
418+
buildOptimisticLockOptions({
419+
versionAttribute,
420+
versionAttributeValue: dataAsMap[versionAttribute],
421+
conditionExpression,
422+
attributeNames,
423+
attributeValues,
424+
}));
425+
}
426+
427+
dataAsMap[versionAttribute] = dataAsMap[versionAttribute]
428+
? dataAsMap[versionAttribute] + 1
429+
: 1;
430+
}
431+
315432
await this.documentClient
316433
.put({
317434
TableName: this.tableName,
318435
Item: data,
319-
ConditionExpression: options.conditionExpression,
320-
ExpressionAttributeNames: options.attributeNames,
321-
ExpressionAttributeValues: options.attributeValues,
436+
ConditionExpression: conditionExpression,
437+
ExpressionAttributeNames: attributeNames,
438+
ExpressionAttributeValues: attributeValues,
322439
})
323440
.promise();
324441
return data;
@@ -337,6 +454,8 @@ export default class DynamoDbDao<DataModel, KeySchema> {
337454
key,
338455
data,
339456
...updateOptions,
457+
optimisticLockVersionAttribute:
458+
this.optimisticLockingAttribute?.toString(),
340459
});
341460
const { Attributes: attributes } = await this.documentClient
342461
.update(params)

0 commit comments

Comments
 (0)