-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
CORE-1836 Optimistic locking with version number #24
Changes from all commits
4899f07
53b0020
bd11283
225ba12
0061c09
9ac66f8
c20ecd9
1f513df
d40b46c
d14fbc4
332e251
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -120,9 +120,57 @@ export interface ConditionalOptions { | |
attributeValues?: AttributeValues; | ||
} | ||
|
||
export type PutOptions = ConditionalOptions; | ||
export type UpdateOptions = ConditionalOptions; | ||
export type DeleteOptions = ConditionalOptions; | ||
export interface SaveBehavior { | ||
optimisticLockVersionAttribute?: string; | ||
optimisticLockVersionIncrement?: number; | ||
} | ||
|
||
export interface MutateBehavior { | ||
ignoreOptimisticLocking?: boolean; | ||
} | ||
|
||
export type PutOptions = ConditionalOptions & MutateBehavior; | ||
export type UpdateOptions = ConditionalOptions & MutateBehavior; | ||
export type DeleteOptions = ConditionalOptions & MutateBehavior; | ||
|
||
export interface BuildOptimisticLockOptionsInput extends ConditionalOptions { | ||
versionAttribute: string; | ||
versionAttributeValue: any; | ||
} | ||
|
||
export function buildOptimisticLockOptions( | ||
options: BuildOptimisticLockOptionsInput | ||
): ConditionalOptions { | ||
const { versionAttribute, versionAttributeValue } = options; | ||
let { conditionExpression, attributeNames, attributeValues } = options; | ||
|
||
const lockExpression = versionAttributeValue | ||
? `#${versionAttribute} = :${versionAttribute}` | ||
: `attribute_not_exists(${versionAttribute})`; | ||
|
||
conditionExpression = conditionExpression | ||
? `(${conditionExpression}) AND ${lockExpression}` | ||
: lockExpression; | ||
|
||
if (versionAttributeValue) { | ||
attributeNames = { | ||
...attributeNames, | ||
[`#${versionAttribute}`]: versionAttribute, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could the attribute name/value properties be post-fixed with a unique value (like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought about that, but since I'm letting the package caller tell me which attribute they want to store the version in (and enforcing it as numeric property of I.e. if there is an overlap - the caller explicitly made that decision. |
||
}; | ||
attributeValues = { | ||
...attributeValues, | ||
[`:${versionAttribute}`]: versionAttributeValue, | ||
}; | ||
} | ||
|
||
return { | ||
conditionExpression, | ||
attributeNames, | ||
attributeValues, | ||
}; | ||
} | ||
|
||
type DataModelAsMap = { [key: string]: any }; | ||
|
||
export interface GenerateUpdateParamsInput extends UpdateOptions { | ||
tableName: string; | ||
|
@@ -131,9 +179,10 @@ export interface GenerateUpdateParamsInput extends UpdateOptions { | |
} | ||
|
||
export function generateUpdateParams( | ||
options: GenerateUpdateParamsInput | ||
options: GenerateUpdateParamsInput & SaveBehavior | ||
): DocumentClient.UpdateItemInput { | ||
const setExpressions: string[] = []; | ||
const addExpressions: string[] = []; | ||
const removeExpressions: string[] = []; | ||
const expressionAttributeNameMap: AttributeNames = {}; | ||
const expressionAttributeValueMap: AttributeValues = {}; | ||
|
@@ -142,15 +191,41 @@ export function generateUpdateParams( | |
tableName, | ||
key, | ||
data, | ||
conditionExpression, | ||
attributeNames, | ||
attributeValues, | ||
optimisticLockVersionAttribute: versionAttribute, | ||
optimisticLockVersionIncrement: versionInc, | ||
ignoreOptimisticLocking: ignoreLocking = false, | ||
} = options; | ||
|
||
let conditionExpression = options.conditionExpression; | ||
|
||
if (versionAttribute) { | ||
addExpressions.push(`#${versionAttribute} :${versionAttribute}Inc`); | ||
expressionAttributeNameMap[`#${versionAttribute}`] = versionAttribute; | ||
expressionAttributeValueMap[`:${versionAttribute}Inc`] = versionInc ?? 1; | ||
|
||
if (!ignoreLocking) { | ||
({ conditionExpression } = buildOptimisticLockOptions({ | ||
versionAttribute, | ||
versionAttributeValue: (data as DataModelAsMap)[versionAttribute], | ||
conditionExpression, | ||
})); | ||
expressionAttributeValueMap[`:${versionAttribute}`] = ( | ||
data as DataModelAsMap | ||
)[versionAttribute]; | ||
} | ||
} | ||
|
||
const keys = Object.keys(options.data).sort(); | ||
|
||
for (let i = 0; i < keys.length; i++) { | ||
const name = keys[i]; | ||
if (name === versionAttribute) { | ||
// versionAttribute is a special case and should always be handled | ||
// explicitly as above with the supplied value ignored | ||
continue; | ||
} | ||
|
||
const valueName = `:a${i}`; | ||
const attributeName = `#a${i}`; | ||
|
@@ -178,11 +253,13 @@ export function generateUpdateParams( | |
? 'remove ' + removeExpressions.join(', ') | ||
: undefined; | ||
|
||
const addString = | ||
addExpressions.length > 0 ? 'add ' + addExpressions.join(', ') : undefined; | ||
return { | ||
TableName: tableName, | ||
Key: key, | ||
ConditionExpression: conditionExpression, | ||
UpdateExpression: [setString, removeString] | ||
UpdateExpression: [addString, setString, removeString] | ||
.filter((val) => val !== undefined) | ||
.join(' '), | ||
ExpressionAttributeNames: { | ||
|
@@ -197,9 +274,10 @@ export function generateUpdateParams( | |
}; | ||
} | ||
|
||
interface DynamoDbDaoInput { | ||
export interface DynamoDbDaoInput<T> { | ||
tableName: string; | ||
documentClient: DocumentClient; | ||
optimisticLockingAttribute?: keyof NumberPropertiesInType<T>; | ||
} | ||
|
||
function invalidCursorError(cursor: string): Error { | ||
|
@@ -261,10 +339,12 @@ export type NumberPropertiesInType<T> = Pick< | |
export default class DynamoDbDao<DataModel, KeySchema> { | ||
public readonly tableName: string; | ||
public readonly documentClient: DocumentClient; | ||
public readonly optimisticLockingAttribute?: keyof NumberPropertiesInType<DataModel>; | ||
|
||
constructor(options: DynamoDbDaoInput) { | ||
constructor(options: DynamoDbDaoInput<DataModel>) { | ||
this.tableName = options.tableName; | ||
this.documentClient = options.documentClient; | ||
this.optimisticLockingAttribute = options.optimisticLockingAttribute; | ||
} | ||
|
||
/** | ||
|
@@ -292,16 +372,30 @@ export default class DynamoDbDao<DataModel, KeySchema> { | |
*/ | ||
async delete( | ||
key: KeySchema, | ||
options: DeleteOptions = {} | ||
options: DeleteOptions = {}, | ||
data: Partial<DataModel> = {} | ||
): Promise<DataModel | undefined> { | ||
let { attributeNames, attributeValues, conditionExpression } = options; | ||
|
||
if (this.optimisticLockingAttribute && !options.ignoreOptimisticLocking) { | ||
const versionAttribute = this.optimisticLockingAttribute.toString(); | ||
({ attributeNames, attributeValues, conditionExpression } = | ||
buildOptimisticLockOptions({ | ||
versionAttribute, | ||
versionAttributeValue: (data as DataModelAsMap)[versionAttribute], | ||
conditionExpression: conditionExpression, | ||
attributeNames, | ||
attributeValues, | ||
})); | ||
} | ||
const { Attributes: attributes } = await this.documentClient | ||
.delete({ | ||
TableName: this.tableName, | ||
Key: key, | ||
ReturnValues: 'ALL_OLD', | ||
ConditionExpression: options.conditionExpression, | ||
ExpressionAttributeNames: options.attributeNames, | ||
ExpressionAttributeValues: options.attributeValues, | ||
ConditionExpression: conditionExpression, | ||
ExpressionAttributeNames: attributeNames, | ||
ExpressionAttributeValues: attributeValues, | ||
}) | ||
.promise(); | ||
|
||
|
@@ -312,13 +406,36 @@ export default class DynamoDbDao<DataModel, KeySchema> { | |
* Creates/Updates an item in the table | ||
*/ | ||
async put(data: DataModel, options: PutOptions = {}): Promise<DataModel> { | ||
let { conditionExpression, attributeNames, attributeValues } = options; | ||
if (this.optimisticLockingAttribute) { | ||
// Must cast data to avoid tripping the linter, otherwise, it'll complain | ||
// about expression of type 'string' can't be used to index type 'unknown' | ||
const dataAsMap = data as DataModelAsMap; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see why this is necessary, but I wonder if its possible to get a bit fancier with the Maybe make the Then you could get fancier with the E.G. type NumberProp<T extends Record<string, any>> = Pick<
T,
{
[K in keyof T]: K extends string ?
T[k] extends number ? K
: never
: never;
}[keyof T]
>; That being said, all of that would become very hard to read & understand so this solution is probably better, we still have the guarantees on the constructor args that its a number attribute, just a slight risk with these casts that it could be used incorrectly in internal methods, but your tests are extremely thorough so thats unlikely. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a nifty idea.. although I think I might need to consider that a potential breaking change since it limits the Apart from that, though, when I tried that, I ran into this => microsoft/TypeScript#31661. 🤮 |
||
const versionAttribute = this.optimisticLockingAttribute.toString(); | ||
|
||
if (!options.ignoreOptimisticLocking) { | ||
({ conditionExpression, attributeNames, attributeValues } = | ||
buildOptimisticLockOptions({ | ||
versionAttribute, | ||
versionAttributeValue: dataAsMap[versionAttribute], | ||
conditionExpression, | ||
attributeNames, | ||
attributeValues, | ||
})); | ||
} | ||
|
||
dataAsMap[versionAttribute] = dataAsMap[versionAttribute] | ||
? dataAsMap[versionAttribute] + 1 | ||
: 1; | ||
} | ||
|
||
await this.documentClient | ||
.put({ | ||
TableName: this.tableName, | ||
Item: data, | ||
ConditionExpression: options.conditionExpression, | ||
ExpressionAttributeNames: options.attributeNames, | ||
ExpressionAttributeValues: options.attributeValues, | ||
ConditionExpression: conditionExpression, | ||
ExpressionAttributeNames: attributeNames, | ||
ExpressionAttributeValues: attributeValues, | ||
}) | ||
.promise(); | ||
return data; | ||
|
@@ -337,6 +454,8 @@ export default class DynamoDbDao<DataModel, KeySchema> { | |
key, | ||
data, | ||
...updateOptions, | ||
optimisticLockVersionAttribute: | ||
this.optimisticLockingAttribute?.toString(), | ||
}); | ||
const { Attributes: attributes } = await this.documentClient | ||
.update(params) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be helpful to add a snippet that shows how to enable optimistic locking for the dao here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea - I've added that (along with a note in the changelog).