Skip to content

Commit d98ca62

Browse files
committed
chore: refactor generic persistence layer
1 parent 7ab0920 commit d98ca62

7 files changed

+107
-135
lines changed

docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts

+53-124
Original file line numberDiff line numberDiff line change
@@ -3,115 +3,92 @@ import {
33
IdempotencyItemNotFoundError,
44
IdempotencyRecordStatus,
55
} from '@aws-lambda-powertools/idempotency';
6-
import {
7-
IdempotencyRecordOptions,
8-
IdempotencyRecordStatusValue,
9-
} from '@aws-lambda-powertools/idempotency/types';
6+
import { IdempotencyRecordOptions } from '@aws-lambda-powertools/idempotency/types';
107
import {
118
IdempotencyRecord,
129
BasePersistenceLayer,
1310
} from '@aws-lambda-powertools/idempotency/persistence';
1411
import { getSecret } from '@aws-lambda-powertools/parameters/secrets';
1512
import { Transform } from '@aws-lambda-powertools/parameters';
1613
import {
17-
CacheClient,
18-
CredentialProvider,
19-
Configurations,
20-
CacheGet,
21-
CacheKeyExists,
22-
CollectionTtl,
23-
CacheDictionarySetFields,
24-
CacheDictionaryGetFields,
25-
} from '@gomomento/sdk';
26-
import type { MomentoApiSecret, Item } from './types';
27-
28-
class MomentoCachePersistenceLayer extends BasePersistenceLayer {
29-
#cacheName: string;
30-
#client?: CacheClient;
31-
32-
public constructor(config: { cacheName: string }) {
14+
ProviderClient,
15+
ProviderItemAlreadyExists,
16+
} from './advancedBringYourOwnPersistenceLayerProvider';
17+
import type { ApiSecret, ProviderItem } from './types';
18+
19+
class CustomPersistenceLayer extends BasePersistenceLayer {
20+
#collectionName: string;
21+
#client?: ProviderClient;
22+
23+
public constructor(config: { collectionName: string }) {
3324
super();
34-
this.#cacheName = config.cacheName;
25+
this.#collectionName = config.collectionName;
3526
}
3627

3728
protected async _deleteRecord(record: IdempotencyRecord): Promise<void> {
3829
await (
3930
await this.#getClient()
40-
).delete(this.#cacheName, record.idempotencyKey);
31+
).delete(this.#collectionName, record.idempotencyKey);
4132
}
4233

4334
protected async _getRecord(
4435
idempotencyKey: string
4536
): Promise<IdempotencyRecord> {
46-
const response = await (
47-
await this.#getClient()
48-
).dictionaryFetch(this.#cacheName, idempotencyKey);
37+
try {
38+
const item = await (
39+
await this.#getClient()
40+
).get(this.#collectionName, idempotencyKey);
4941

50-
if (
51-
response instanceof CacheGet.Error ||
52-
response instanceof CacheGet.Miss
53-
) {
42+
return new IdempotencyRecord({
43+
...(item as unknown as IdempotencyRecordOptions),
44+
});
45+
} catch (error) {
5446
throw new IdempotencyItemNotFoundError();
5547
}
56-
const { data, ...rest } =
57-
response.value() as unknown as IdempotencyRecordOptions & {
58-
data: string;
59-
};
60-
61-
return new IdempotencyRecord({
62-
responseData: JSON.parse(data),
63-
...rest,
64-
});
6548
}
6649

6750
protected async _putRecord(record: IdempotencyRecord): Promise<void> {
68-
const item: Partial<Item> = {
51+
const item: Partial<ProviderItem> = {
6952
status: record.getStatus(),
7053
};
7154

7255
if (record.inProgressExpiryTimestamp !== undefined) {
73-
item.in_progress_expiration = record.inProgressExpiryTimestamp.toString();
56+
item.in_progress_expiration = record.inProgressExpiryTimestamp;
7457
}
7558

7659
if (this.isPayloadValidationEnabled() && record.payloadHash !== undefined) {
7760
item.validation = record.payloadHash;
7861
}
7962

80-
try {
81-
const lock = await this.#lookupItem(record.idempotencyKey);
82-
83-
if (
84-
lock.getStatus() !== IdempotencyRecordStatus.INPROGRESS &&
85-
(lock.inProgressExpiryTimestamp || 0) < Date.now()
86-
) {
87-
throw new IdempotencyItemAlreadyExistsError(
88-
`Failed to put record for already existing idempotency key: ${record.idempotencyKey}`
89-
);
90-
}
91-
} catch (error) {
92-
if (error instanceof IdempotencyItemAlreadyExistsError) {
93-
throw error;
94-
}
95-
}
96-
9763
const ttl = record.expiryTimestamp
9864
? Math.floor(new Date(record.expiryTimestamp * 1000).getTime() / 1000) -
9965
Math.floor(new Date().getTime() / 1000)
10066
: this.getExpiresAfterSeconds();
10167

102-
const response = await (
103-
await this.#getClient()
104-
).dictionarySetFields(this.#cacheName, record.idempotencyKey, item, {
105-
ttl: CollectionTtl.of(ttl).withNoRefreshTtlOnUpdates(),
106-
});
107-
108-
if (response instanceof CacheDictionarySetFields.Error) {
109-
throw new Error(`Unable to put item: ${response.errorCode()}`);
68+
let existingItem: ProviderItem | undefined;
69+
try {
70+
existingItem = await (
71+
await this.#getClient()
72+
).put(this.#collectionName, record.idempotencyKey, item, {
73+
ttl,
74+
});
75+
} catch (error) {
76+
if (error instanceof ProviderItemAlreadyExists) {
77+
if (
78+
existingItem &&
79+
existingItem.status !== IdempotencyRecordStatus.INPROGRESS &&
80+
(existingItem.in_progress_expiration || 0) < Date.now()
81+
) {
82+
throw new IdempotencyItemAlreadyExistsError(
83+
`Failed to put record for already existing idempotency key: ${record.idempotencyKey}`
84+
);
85+
}
86+
}
11087
}
11188
}
11289

11390
protected async _updateRecord(record: IdempotencyRecord): Promise<void> {
114-
const value: Partial<Item> = {
91+
const value: Partial<ProviderItem> = {
11592
data: JSON.stringify(record.responseData),
11693
status: record.getStatus(),
11794
};
@@ -120,82 +97,34 @@ class MomentoCachePersistenceLayer extends BasePersistenceLayer {
12097
value.validation = record.payloadHash;
12198
}
12299

123-
await this.#checkItemExists(record.idempotencyKey);
124-
125100
await (
126101
await this.#getClient()
127-
).dictionarySetFields(this.#cacheName, record.idempotencyKey, value, {
128-
ttl: CollectionTtl.refreshTtlIfProvided().withNoRefreshTtlOnUpdates(),
129-
});
102+
).update(this.#collectionName, record.idempotencyKey, value);
130103
}
131104

132-
async #getMomentoApiSecret(): Promise<MomentoApiSecret> {
133-
const secretName = process.env.MOMENTO_API_SECRET;
105+
async #getClient(): Promise<ProviderClient> {
106+
if (this.#client) return this.#client;
107+
108+
const secretName = process.env.API_SECRET;
134109
if (!secretName) {
135-
throw new Error('MOMENTO_API_SECRET environment variable is not set');
110+
throw new Error('API_SECRET environment variable is not set');
136111
}
137112

138-
const apiSecret = await getSecret<MomentoApiSecret>(secretName, {
113+
const apiSecret = await getSecret<ApiSecret>(secretName, {
139114
transform: Transform.JSON,
140115
});
141116

142117
if (!apiSecret) {
143118
throw new Error(`Could not retrieve secret ${secretName}`);
144119
}
145120

146-
return apiSecret;
147-
}
148-
149-
async #getClient(): Promise<CacheClient> {
150-
if (this.#client) return this.#client;
151-
152-
const apiSecret = await this.#getMomentoApiSecret();
153-
this.#client = await CacheClient.create({
154-
configuration: Configurations.InRegion.LowLatency.latest(),
155-
credentialProvider: CredentialProvider.fromString({
156-
apiKey: apiSecret.apiKey,
157-
}),
121+
this.#client = new ProviderClient({
122+
apiKey: apiSecret.apiKey,
158123
defaultTtlSeconds: this.getExpiresAfterSeconds(),
159124
});
160125

161126
return this.#client;
162127
}
163-
164-
async #checkItemExists(idempotencyKey: string): Promise<boolean> {
165-
const response = await (
166-
await this.#getClient()
167-
).keysExist(this.#cacheName, [idempotencyKey]);
168-
169-
return response instanceof CacheKeyExists.Success;
170-
}
171-
172-
async #lookupItem(idempotencyKey: string): Promise<IdempotencyRecord> {
173-
const response = await (
174-
await this.#getClient()
175-
).dictionaryGetFields(this.#cacheName, idempotencyKey, [
176-
'in_progress_expiration',
177-
'status',
178-
]);
179-
180-
if (response instanceof CacheDictionaryGetFields.Miss) {
181-
throw new IdempotencyItemNotFoundError();
182-
} else if (response instanceof CacheDictionaryGetFields.Error) {
183-
throw new Error('Unable to get item');
184-
} else {
185-
const { status, in_progress_expiration: inProgressExpiryTimestamp } =
186-
response.value() || {};
187-
188-
if (status !== undefined || inProgressExpiryTimestamp !== undefined) {
189-
throw new Error('Unable');
190-
}
191-
192-
return new IdempotencyRecord({
193-
idempotencyKey,
194-
status: status as IdempotencyRecordStatusValue,
195-
inProgressExpiryTimestamp: parseFloat(inProgressExpiryTimestamp),
196-
});
197-
}
198-
}
199128
}
200129

201-
export { MomentoCachePersistenceLayer };
130+
export { CustomPersistenceLayer };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ProviderItem } from './types';
2+
3+
/**
4+
* This is a mock implementation of an SDK client for a generic key-value store.
5+
*/
6+
class ProviderClient {
7+
public constructor(_config: { apiKey: string; defaultTtlSeconds: number }) {
8+
// ...
9+
}
10+
11+
public async delete(_collectionName: string, _key: string): Promise<void> {
12+
// ...
13+
}
14+
15+
public async get(
16+
_collectionName: string,
17+
_key: string
18+
): Promise<ProviderItem> {
19+
// ...
20+
return {} as ProviderItem;
21+
}
22+
23+
public async put(
24+
_collectionName: string,
25+
_key: string,
26+
_value: Partial<ProviderItem>,
27+
_options: { ttl: number }
28+
): Promise<ProviderItem> {
29+
// ...
30+
return {} as ProviderItem;
31+
}
32+
33+
public async update(
34+
_collectionName: string,
35+
_key: string,
36+
_value: Partial<ProviderItem>
37+
): Promise<void> {
38+
// ...
39+
}
40+
}
41+
42+
class ProviderItemAlreadyExists extends Error {}
43+
44+
export { ProviderClient, ProviderItemAlreadyExists };

docs/snippets/idempotency/advancedBringYourOwnPersistenceLayerUsage.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { Context } from 'aws-lambda';
22
import { randomUUID } from 'node:crypto';
3-
import { MomentoCachePersistenceLayer } from './advancedBringYourOwnPersistenceLayer';
3+
import { CustomPersistenceLayer } from './advancedBringYourOwnPersistenceLayer';
44
import {
55
IdempotencyConfig,
66
makeIdempotent,
77
} from '@aws-lambda-powertools/idempotency';
88
import type { Request, Response, SubscriptionResult } from './types';
99

10-
const persistenceStore = new MomentoCachePersistenceLayer({
11-
cacheName: 'powertools',
10+
const persistenceStore = new CustomPersistenceLayer({
11+
collectionName: 'powertools',
1212
});
1313
const config = new IdempotencyConfig({
1414
expiresAfterSeconds: 60,

docs/snippets/idempotency/templates/tableCdk.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
44
import { Runtime } from 'aws-cdk-lib/aws-lambda';
55
import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb';
66

7-
export class IdempotencyMomentoStack extends Stack {
7+
export class IdempotencyStack extends Stack {
88
public constructor(scope: Construct, id: string, props?: StackProps) {
99
super(scope, id, props);
1010

docs/snippets/idempotency/types.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ export type SubscriptionResult = {
1414
productId: string;
1515
};
1616

17-
export type MomentoApiSecret = {
17+
export type ApiSecret = {
1818
apiKey: string;
1919
refreshToken: string;
2020
validUntil: number;
2121
restEndpoint: string;
2222
};
2323

24-
export type Item = {
24+
export type ProviderItem = {
2525
validation?: string;
26-
in_progress_expiration?: string;
26+
in_progress_expiration?: number;
2727
status: IdempotencyRecordStatusValue;
2828
data: string;
2929
};

docs/snippets/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"@aws-sdk/client-secrets-manager": "^3.360.0",
3434
"@aws-sdk/client-ssm": "^3.360.0",
3535
"@aws-sdk/util-dynamodb": "^3.360.0",
36-
"@gomomento/sdk": "^1.39.3",
3736
"aws-sdk": "^2.1405.0",
3837
"aws-sdk-client-mock": "^2.2.0",
3938
"aws-sdk-client-mock-jest": "^2.2.0",

docs/utilities/idempotency.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -697,11 +697,11 @@ You can create your own persistent store from scratch by inheriting the `BasePer
697697
* `_updateRecord()` – Updates an item in the persistence store.
698698
* `_deleteRecord()` – Removes an item from the persistence store.
699699

700-
Below an example of an alternative persistence layer backed by [Momento Cache](https://www.gomomento.com):
700+
Below an example implementation of a custom persistence layer backed by a generic key-value store.
701701

702-
=== "MomentoCachePersistenceLayer"
702+
=== "CustomPersistenceLayer"
703703

704-
```typescript hl_lines="12 28 37 43 67 113"
704+
```typescript hl_lines="9 19 28 34 50 90"
705705
--8<-- "docs/snippets/idempotency/advancedBringYourOwnPersistenceLayer.ts"
706706
```
707707

0 commit comments

Comments
 (0)