Skip to content

Commit 6b962ae

Browse files
Merge pull request #4 from jeffrey-baker-vg/idempotency-base-persistence
feat: adding PersistenceLayer
2 parents 7eb52a1 + 3cae869 commit 6b962ae

13 files changed

+486
-37
lines changed

Diff for: layer-publisher/12'

Whitespace-only changes.

Diff for: packages/idempotency/jest.config.js

-3
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,5 @@ module.exports = {
3838
'json-summary',
3939
'text',
4040
'lcov'
41-
],
42-
'setupFiles': [
43-
'<rootDir>/tests/helpers/populateEnvironmentVariables.ts'
4441
]
4542
};
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class EnvironmentVariablesService {
2+
private lambdaFunctionNameVariable = 'AWS_LAMBDA_FUNCTION_NAME';
3+
4+
/**
5+
* retrieve the value of an environment variable
6+
*
7+
* @param name the name of the environment variable
8+
* @returns the value of the environment variable
9+
*/
10+
public get(name: string): string {
11+
return process.env[name]?.trim() || '';
12+
}
13+
/**
14+
* retrieve the name of the Lambda function
15+
*
16+
* @returns the Lambda function name
17+
*/
18+
public getLambdaFunctionName(): string{
19+
return this.get(this.lambdaFunctionNameVariable);
20+
}
21+
}
22+
23+
export {
24+
EnvironmentVariablesService
25+
};

Diff for: packages/idempotency/src/persistence/DynamoDbPersistenceLayer.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-empty-function */
2+
23
import { DynamoDB, DynamoDBServiceException } from '@aws-sdk/client-dynamodb';
34
import { DynamoDBDocument, GetCommandOutput } from '@aws-sdk/lib-dynamodb';
45
import { IdempotencyItemAlreadyExistsError, IdempotencyItemNotFoundError } from '../Exceptions';
56
import { IdempotencyRecordStatus } from '../types/IdempotencyRecordStatus';
6-
import { IdempotencyRecord, PersistenceLayer } from './PersistenceLayer';
7+
import { PersistenceLayer } from './PersistenceLayer';
8+
import { IdempotencyRecord } from './IdempotencyRecord';
79

810
class DynamoDBPersistenceLayer extends PersistenceLayer {
911
private _table: DynamoDBDocument | undefined;
@@ -47,7 +49,7 @@ class DynamoDBPersistenceLayer extends PersistenceLayer {
4749
const notInProgress = 'NOT #status = :inprogress';
4850
const conditionalExpression = `${idempotencyKeyDoesNotExist} OR ${idempotencyKeyExpired} OR ${notInProgress}`;
4951
try {
50-
await table.put({ TableName: this.tableName, Item: item, ExpressionAttributeNames: { '#id': this.key_attr, '#expiry': this.expiry_attr, '#status': this.status_attr }, ExpressionAttributeValues: { ':now': Date.now(), ':inprogress': IdempotencyRecordStatus.INPROGRESS }, ConditionExpression: conditionalExpression });
52+
await table.put({ TableName: this.tableName, Item: item, ExpressionAttributeNames: { '#id': this.key_attr, '#expiry': this.expiry_attr, '#status': this.status_attr }, ExpressionAttributeValues: { ':now': Date.now() / 1000, ':inprogress': IdempotencyRecordStatus.INPROGRESS }, ConditionExpression: conditionalExpression });
5153
} catch (e){
5254
if ((e as DynamoDBServiceException).name === 'ConditionalCheckFailedException'){
5355
throw new IdempotencyItemAlreadyExistsError();

Diff for: packages/idempotency/src/persistence/IdempotencyRecord.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class IdempotencyRecord {
2525
}
2626

2727
private isExpired(): boolean {
28-
return this.expiryTimestamp !== undefined && (Date.now() > this.expiryTimestamp);
28+
return this.expiryTimestamp !== undefined && ((Date.now() / 1000) > this.expiryTimestamp);
2929
}
3030
}
3131

+145-8
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,161 @@
11
/* eslint-disable @typescript-eslint/no-empty-function */
2+
import { BinaryToTextEncoding, createHash, Hash } from 'crypto';
3+
import { IdempotencyRecordStatus } from '../types/IdempotencyRecordStatus';
4+
import { EnvironmentVariablesService } from '../EnvironmentVariablesService';
25
import { IdempotencyRecord } from './IdempotencyRecord';
36
import { PersistenceLayerInterface } from './PersistenceLayerInterface';
47

58
abstract class PersistenceLayer implements PersistenceLayerInterface {
6-
public constructor() { }
7-
public configure(_functionName: string = ''): void {}
8-
public async deleteRecord(): Promise<void> { }
9-
public async getRecord(): Promise<IdempotencyRecord> {
10-
return Promise.resolve({} as IdempotencyRecord);
9+
10+
// envVarsService is always initialized in the constructor
11+
private envVarsService!: EnvironmentVariablesService;
12+
13+
private expiresAfterSeconds: number;
14+
15+
private functionName: string = '';
16+
17+
private hashDigest: BinaryToTextEncoding;
18+
19+
private hashFunction: string;
20+
21+
public constructor() {
22+
this.setEnvVarsService();
23+
this.expiresAfterSeconds = 60 * 60; //one hour is the default expiration
24+
this.hashFunction = 'md5';
25+
this.hashDigest = 'base64';
26+
27+
}
28+
public configure(functionName: string = ''): void {
29+
this.functionName = this.getEnvVarsService().getLambdaFunctionName() + '.' + functionName;
30+
}
31+
32+
/**
33+
* Deletes a record from the persistence store for the persistence key generated from the data passed in.
34+
*
35+
* @param data - the data payload that will be hashed to create the hash portion of the idempotency key
36+
*/
37+
public async deleteRecord(data: unknown): Promise<void> {
38+
const idempotencyRecord: IdempotencyRecord =
39+
new IdempotencyRecord(this.getHashedIdempotencyKey(data),
40+
IdempotencyRecordStatus.EXPIRED,
41+
undefined,
42+
undefined,
43+
undefined,
44+
undefined
45+
);
46+
47+
this._deleteRecord(idempotencyRecord);
48+
}
49+
/**
50+
* Retrieves idempotency key for the provided data and fetches data for that key from the persistence store
51+
*
52+
* @param data - the data payload that will be hashed to create the hash portion of the idempotency key
53+
*/
54+
public async getRecord(data: unknown): Promise<IdempotencyRecord> {
55+
const idempotencyKey: string = this.getHashedIdempotencyKey(data);
56+
57+
return this._getRecord(idempotencyKey);
58+
}
59+
60+
/**
61+
* Saves a record indicating that the function's execution is currently in progress
62+
*
63+
* @param data - the data payload that will be hashed to create the hash portion of the idempotency key
64+
*/
65+
public async saveInProgress(data: unknown): Promise<void> {
66+
const idempotencyRecord: IdempotencyRecord =
67+
new IdempotencyRecord(this.getHashedIdempotencyKey(data),
68+
IdempotencyRecordStatus.INPROGRESS,
69+
this.getExpiryTimestamp(),
70+
undefined,
71+
undefined,
72+
undefined
73+
);
74+
75+
return this._putRecord(idempotencyRecord);
76+
}
77+
78+
/**
79+
* Saves a record of the function completing successfully. This will create a record with a COMPLETED status
80+
* and will save the result of the completed function in the idempotency record.
81+
*
82+
* @param data - the data payload that will be hashed to create the hash portion of the idempotency key
83+
* @param result - the result of the successfully completed function
84+
*/
85+
public async saveSuccess(data: unknown, result: Record<string, unknown>): Promise<void> {
86+
const idempotencyRecord: IdempotencyRecord =
87+
new IdempotencyRecord(this.getHashedIdempotencyKey(data),
88+
IdempotencyRecordStatus.COMPLETED,
89+
this.getExpiryTimestamp(),
90+
undefined,
91+
result,
92+
undefined
93+
);
94+
95+
this._updateRecord(idempotencyRecord);
1196
}
12-
public async saveInProgress(): Promise<void> { }
13-
public async saveSuccess(): Promise<void> { }
1497

1598
protected abstract _deleteRecord(record: IdempotencyRecord): Promise<void>;
1699
protected abstract _getRecord(idempotencyKey: string): Promise<IdempotencyRecord>;
17100
protected abstract _putRecord(record: IdempotencyRecord): Promise<void>;
18101
protected abstract _updateRecord(record: IdempotencyRecord): Promise<void>;
102+
103+
/**
104+
* Generates a hash of the data and returns the digest of that hash
105+
*
106+
* @param data the data payload that will generate the hash
107+
* @returns the digest of the generated hash
108+
*/
109+
private generateHash(data: string): string{
110+
const hash: Hash = createHash(this.hashFunction);
111+
hash.update(data);
112+
113+
return hash.digest(this.hashDigest);
114+
}
115+
116+
/**
117+
* Getter for `envVarsService`.
118+
* Used internally during initialization.
119+
*/
120+
private getEnvVarsService(): EnvironmentVariablesService {
121+
return this.envVarsService;
122+
}
123+
124+
/**
125+
* Creates the expiry timestamp for the idempotency record
126+
*
127+
* @returns the expiry time for the record expressed as number of seconds past the UNIX epoch
128+
*/
129+
private getExpiryTimestamp(): number {
130+
const currentTime: number = Date.now() / 1000;
131+
132+
return currentTime + this.expiresAfterSeconds;
133+
}
134+
135+
/**
136+
* Generates the idempotency key used to identify records in the persistence store.
137+
*
138+
* @param data the data payload that will be hashed to create the hash portion of the idempotency key
139+
* @returns the idempotency key
140+
*/
141+
private getHashedIdempotencyKey(data: unknown): string {
142+
if (!data){
143+
console.warn('No data found for idempotency key');
144+
}
145+
146+
return this.functionName + '#' + this.generateHash(JSON.stringify(data));
147+
}
148+
149+
/**
150+
* Setter and initializer for `envVarsService`.
151+
* Used internally during initialization.
152+
*/
153+
private setEnvVarsService(): void {
154+
this.envVarsService = new EnvironmentVariablesService();
155+
}
156+
19157
}
20158

21159
export {
22-
IdempotencyRecord,
23160
PersistenceLayer
24161
};
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { IdempotencyRecord } from './PersistenceLayer';
1+
import { IdempotencyRecord } from './IdempotencyRecord';
22

33
interface PersistenceLayerInterface {
44
configure(functionName: string): void
5-
saveInProgress(): Promise<void>
6-
saveSuccess(): Promise<void>
7-
deleteRecord(): Promise<void>
8-
getRecord(): Promise<IdempotencyRecord>
5+
saveInProgress(data: unknown): Promise<void>
6+
saveSuccess(data: unknown, result: unknown): Promise<void>
7+
deleteRecord(data: unknown): Promise<void>
8+
getRecord(data: unknown): Promise<IdempotencyRecord>
99
}
1010

1111
export { PersistenceLayerInterface };

Diff for: packages/idempotency/src/persistence/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './DynamoDbPersistenceLayer';
2+
export * from './PersistenceLayer';
3+
export * from './PersistenceLayerInterface';
4+
export * from './IdempotencyRecord';

Diff for: packages/idempotency/src/types/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './AnyFunction';
2+
export * from './IdempotencyRecordStatus';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Test Tracer class
3+
*
4+
* @group unit/idempotency/all
5+
*/
6+
import { EnvironmentVariablesService } from '../../src/EnvironmentVariablesService';
7+
8+
describe('Class: EnvironmentVariableService', ()=> {
9+
describe('Method: getLambdaFunctionName', ()=> {
10+
beforeEach(() => {
11+
process.env.AWS_LAMBDA_FUNCTION_NAME = 'testFunction';
12+
});
13+
14+
afterEach(()=> {
15+
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
16+
});
17+
18+
test('When called it getse the Lambda function name from the environment variable', ()=> {
19+
const expectedName = process.env.AWS_LAMBDA_FUNCTION_NAME;
20+
21+
const lambdaName = new EnvironmentVariablesService().getLambdaFunctionName();
22+
23+
expect(lambdaName).toEqual(expectedName);
24+
});
25+
26+
test('When called without the environment variable set it returns an empty string', ()=> {
27+
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
28+
29+
const lambdaName = new EnvironmentVariablesService().getLambdaFunctionName();
30+
31+
expect(lambdaName).toEqual('');
32+
});
33+
});
34+
});

Diff for: packages/idempotency/tests/unit/persistence/DynamoDbPersistenceLayer.test.ts

+10-13
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ describe('Class: DynamoDbPersistenceLayer', () => {
3636
});
3737

3838
describe('Method: _putRecord', () => {
39+
const currentDateInMilliseconds = 1000;
40+
const currentDateInSeconds = 1;
41+
42+
beforeEach(() => {
43+
jest.spyOn(Date, 'now').mockReturnValue(currentDateInMilliseconds);
44+
});
45+
3946
test('when called with a record that succeeds condition, it puts record in dynamo table', async () => {
4047
// Prepare
4148
const tableName = 'tableName';
@@ -46,10 +53,6 @@ describe('Class: DynamoDbPersistenceLayer', () => {
4653
const expiryTimestamp = 0;
4754
const inProgressExpiryTimestamp = 0;
4855
const record = new IdempotencyRecord(key, status, expiryTimestamp, inProgressExpiryTimestamp, undefined, undefined);
49-
50-
const currentDate = 1;
51-
jest.spyOn(Date, 'now').mockReturnValue(currentDate);
52-
5356
const dynamoClient = mockClient(DynamoDBDocument).on(PutCommand).resolves({});
5457

5558
// Act
@@ -60,7 +63,7 @@ describe('Class: DynamoDbPersistenceLayer', () => {
6063
TableName: tableName,
6164
Item: { 'id': key, 'expiration': expiryTimestamp, status: status },
6265
ExpressionAttributeNames: { '#id': 'id', '#expiry': 'expiration', '#status': 'status' },
63-
ExpressionAttributeValues: { ':now': currentDate, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
66+
ExpressionAttributeValues: { ':now': currentDateInSeconds, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
6467
ConditionExpression: 'attribute_not_exists(#id) OR #expiry < :now OR NOT #status = :inprogress'
6568
});
6669
});
@@ -76,9 +79,6 @@ describe('Class: DynamoDbPersistenceLayer', () => {
7679
const inProgressExpiryTimestamp = 0;
7780
const record = new IdempotencyRecord(key, status, expiryTimestamp, inProgressExpiryTimestamp, undefined, undefined);
7881

79-
const currentDate = 1;
80-
jest.spyOn(Date, 'now').mockReturnValue(currentDate);
81-
8282
const dynamoClient = mockClient(DynamoDBDocument).on(PutCommand).rejects({ name: 'ConditionalCheckFailedException' });
8383

8484
// Act
@@ -94,7 +94,7 @@ describe('Class: DynamoDbPersistenceLayer', () => {
9494
TableName: tableName,
9595
Item: { 'id': key, 'expiration': expiryTimestamp, status: status },
9696
ExpressionAttributeNames: { '#id': 'id', '#expiry': 'expiration', '#status': 'status' },
97-
ExpressionAttributeValues: { ':now': currentDate, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
97+
ExpressionAttributeValues: { ':now': currentDateInSeconds, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
9898
ConditionExpression: 'attribute_not_exists(#id) OR #expiry < :now OR NOT #status = :inprogress'
9999
});
100100
expect(error).toBeInstanceOf(IdempotencyItemAlreadyExistsError);
@@ -111,9 +111,6 @@ describe('Class: DynamoDbPersistenceLayer', () => {
111111
const inProgressExpiryTimestamp = 0;
112112
const record = new IdempotencyRecord(key, status, expiryTimestamp, inProgressExpiryTimestamp, undefined, undefined);
113113

114-
const currentDate = 1;
115-
jest.spyOn(Date, 'now').mockReturnValue(currentDate);
116-
117114
const dynamoClient = mockClient(DynamoDBDocument).on(PutCommand).rejects(new Error());
118115

119116
// Act
@@ -129,7 +126,7 @@ describe('Class: DynamoDbPersistenceLayer', () => {
129126
TableName: tableName,
130127
Item: { 'id': key, 'expiration': expiryTimestamp, status: status },
131128
ExpressionAttributeNames: { '#id': 'id', '#expiry': 'expiration', '#status': 'status' },
132-
ExpressionAttributeValues: { ':now': currentDate, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
129+
ExpressionAttributeValues: { ':now': currentDateInSeconds, ':inprogress': IdempotencyRecordStatus.INPROGRESS },
133130
ConditionExpression: 'attribute_not_exists(#id) OR #expiry < :now OR NOT #status = :inprogress'
134131
});
135132
expect(error).toBe(error);

0 commit comments

Comments
 (0)