@outbox-event-bus/dynamodb-aws-sdk-outbox 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +469 -0
- package/dist/index.cjs +224 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +85 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +85 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +221 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +56 -0
- package/src/dynamodb-aws-sdk-outbox.test.ts +117 -0
- package/src/dynamodb-aws-sdk-outbox.ts +325 -0
- package/src/index.ts +2 -0
- package/src/integration.e2e.ts +465 -0
- package/src/transaction-storage.ts +25 -0
- package/src/transactional.test.ts +98 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { ConditionalCheckFailedException, type DynamoDBClient } from "@aws-sdk/client-dynamodb"
|
|
2
|
+
import {
|
|
3
|
+
QueryCommand as DocQueryCommand,
|
|
4
|
+
DynamoDBDocumentClient,
|
|
5
|
+
TransactWriteCommand,
|
|
6
|
+
type TransactWriteCommandInput,
|
|
7
|
+
UpdateCommand,
|
|
8
|
+
} from "@aws-sdk/lib-dynamodb"
|
|
9
|
+
import {
|
|
10
|
+
BatchSizeLimitError,
|
|
11
|
+
type BusEvent,
|
|
12
|
+
type ErrorHandler,
|
|
13
|
+
EventStatus,
|
|
14
|
+
type FailedBusEvent,
|
|
15
|
+
formatErrorMessage,
|
|
16
|
+
type IOutbox,
|
|
17
|
+
type OutboxConfig,
|
|
18
|
+
PollingService,
|
|
19
|
+
reportEventError,
|
|
20
|
+
} from "outbox-event-bus"
|
|
21
|
+
|
|
22
|
+
// DynamoDB has a hard limit of 100 items per transaction
|
|
23
|
+
const DYNAMODB_TRANSACTION_LIMIT = 100
|
|
24
|
+
|
|
25
|
+
export type TransactWriteItem = NonNullable<TransactWriteCommandInput["TransactItems"]>[number]
|
|
26
|
+
|
|
27
|
+
export type DynamoDBAwsSdkTransactionCollector = {
|
|
28
|
+
push: (item: TransactWriteItem) => void
|
|
29
|
+
items?: TransactWriteItem[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DynamoDBAwsSdkOutboxConfig extends OutboxConfig {
|
|
33
|
+
client: DynamoDBClient
|
|
34
|
+
tableName: string
|
|
35
|
+
statusIndexName?: string
|
|
36
|
+
processingTimeoutMs?: number // Time before a PROCESSING event is considered stuck
|
|
37
|
+
getCollector?: (() => DynamoDBAwsSdkTransactionCollector | undefined) | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DynamoDBAwsSdkOutbox implements IOutbox<DynamoDBAwsSdkTransactionCollector> {
|
|
41
|
+
private readonly config: Required<DynamoDBAwsSdkOutboxConfig>
|
|
42
|
+
private readonly docClient: DynamoDBDocumentClient
|
|
43
|
+
private readonly poller: PollingService
|
|
44
|
+
|
|
45
|
+
constructor(config: DynamoDBAwsSdkOutboxConfig) {
|
|
46
|
+
this.config = {
|
|
47
|
+
batchSize: config.batchSize ?? 50,
|
|
48
|
+
pollIntervalMs: config.pollIntervalMs ?? 1000,
|
|
49
|
+
maxRetries: config.maxRetries ?? 5,
|
|
50
|
+
baseBackoffMs: config.baseBackoffMs ?? 1000,
|
|
51
|
+
processingTimeoutMs: config.processingTimeoutMs ?? 30000,
|
|
52
|
+
maxErrorBackoffMs: config.maxErrorBackoffMs ?? 30000,
|
|
53
|
+
tableName: config.tableName,
|
|
54
|
+
statusIndexName: config.statusIndexName ?? "status-gsiSortKey-index",
|
|
55
|
+
client: config.client,
|
|
56
|
+
getCollector: config.getCollector,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.docClient = DynamoDBDocumentClient.from(config.client, {
|
|
60
|
+
marshallOptions: { removeUndefinedValues: true },
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
this.poller = new PollingService({
|
|
64
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
65
|
+
baseBackoffMs: this.config.baseBackoffMs,
|
|
66
|
+
maxErrorBackoffMs: this.config.maxErrorBackoffMs,
|
|
67
|
+
performMaintenance: () => this.recoverStuckEvents(),
|
|
68
|
+
processBatch: (handler) => this.processBatch(handler),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async publish(
|
|
73
|
+
events: BusEvent[],
|
|
74
|
+
transaction?: DynamoDBAwsSdkTransactionCollector
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (events.length === 0) return
|
|
77
|
+
|
|
78
|
+
if (events.length > DYNAMODB_TRANSACTION_LIMIT) {
|
|
79
|
+
throw new BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, events.length)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const collector = transaction ?? this.config.getCollector?.()
|
|
83
|
+
|
|
84
|
+
const items = events.map((event) => {
|
|
85
|
+
return {
|
|
86
|
+
Put: {
|
|
87
|
+
TableName: this.config.tableName,
|
|
88
|
+
Item: {
|
|
89
|
+
id: event.id,
|
|
90
|
+
type: event.type,
|
|
91
|
+
payload: event.payload,
|
|
92
|
+
occurredAt: event.occurredAt.toISOString(),
|
|
93
|
+
status: EventStatus.CREATED,
|
|
94
|
+
retryCount: 0,
|
|
95
|
+
gsiSortKey: event.occurredAt.getTime(),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (collector) {
|
|
102
|
+
const itemsInCollector = collector.items?.length ?? 0
|
|
103
|
+
|
|
104
|
+
if (itemsInCollector + items.length > DYNAMODB_TRANSACTION_LIMIT) {
|
|
105
|
+
throw new BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, itemsInCollector + items.length)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
collector.push(item)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await this.docClient.send(
|
|
113
|
+
new TransactWriteCommand({
|
|
114
|
+
TransactItems: items,
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getFailedEvents(): Promise<FailedBusEvent[]> {
|
|
121
|
+
const result = await this.docClient.send(
|
|
122
|
+
new DocQueryCommand({
|
|
123
|
+
TableName: this.config.tableName,
|
|
124
|
+
IndexName: this.config.statusIndexName,
|
|
125
|
+
KeyConditionExpression: "#status = :status",
|
|
126
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
127
|
+
ExpressionAttributeValues: { ":status": EventStatus.FAILED },
|
|
128
|
+
Limit: 100,
|
|
129
|
+
ScanIndexForward: false,
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if (!result.Items) return []
|
|
134
|
+
|
|
135
|
+
return result.Items.map((item) => {
|
|
136
|
+
const event: FailedBusEvent = {
|
|
137
|
+
id: item.id,
|
|
138
|
+
type: item.type,
|
|
139
|
+
payload: item.payload,
|
|
140
|
+
occurredAt: this.parseOccurredAt(item.occurredAt),
|
|
141
|
+
retryCount: item.retryCount || 0,
|
|
142
|
+
}
|
|
143
|
+
if (item.lastError) event.error = item.lastError
|
|
144
|
+
if (item.startedOn) event.lastAttemptAt = new Date(item.startedOn)
|
|
145
|
+
return event
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async retryEvents(eventIds: string[]): Promise<void> {
|
|
150
|
+
if (eventIds.length === 0) return
|
|
151
|
+
|
|
152
|
+
await Promise.all(
|
|
153
|
+
eventIds.map((id) =>
|
|
154
|
+
this.docClient.send(
|
|
155
|
+
new UpdateCommand({
|
|
156
|
+
TableName: this.config.tableName,
|
|
157
|
+
Key: { id },
|
|
158
|
+
UpdateExpression:
|
|
159
|
+
"SET #status = :pending, retryCount = :zero, gsiSortKey = :now REMOVE lastError, nextRetryAt, startedOn",
|
|
160
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
161
|
+
ExpressionAttributeValues: {
|
|
162
|
+
":pending": EventStatus.CREATED,
|
|
163
|
+
":zero": 0,
|
|
164
|
+
":now": Date.now(),
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void {
|
|
173
|
+
this.poller.start(handler, onError)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async stop(): Promise<void> {
|
|
177
|
+
await this.poller.stop()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async processBatch(handler: (event: BusEvent) => Promise<void>) {
|
|
181
|
+
const now = Date.now()
|
|
182
|
+
|
|
183
|
+
const result = await this.docClient.send(
|
|
184
|
+
new DocQueryCommand({
|
|
185
|
+
TableName: this.config.tableName,
|
|
186
|
+
IndexName: this.config.statusIndexName,
|
|
187
|
+
KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
|
|
188
|
+
ExpressionAttributeNames: {
|
|
189
|
+
"#status": "status",
|
|
190
|
+
},
|
|
191
|
+
ExpressionAttributeValues: {
|
|
192
|
+
":status": EventStatus.CREATED,
|
|
193
|
+
":now": now,
|
|
194
|
+
},
|
|
195
|
+
Limit: this.config.batchSize,
|
|
196
|
+
})
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if (!result.Items || result.Items.length === 0) return
|
|
200
|
+
|
|
201
|
+
for (const item of result.Items) {
|
|
202
|
+
const event: BusEvent = {
|
|
203
|
+
id: item.id,
|
|
204
|
+
type: item.type,
|
|
205
|
+
payload: item.payload,
|
|
206
|
+
occurredAt: this.parseOccurredAt(item.occurredAt),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await this.markEventAsProcessing(item.id, now)
|
|
211
|
+
await handler(event)
|
|
212
|
+
await this.markEventAsCompleted(event.id)
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof ConditionalCheckFailedException) {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const newRetryCount = (item.retryCount || 0) + 1
|
|
219
|
+
reportEventError(this.poller.onError, error, event, newRetryCount, this.config.maxRetries)
|
|
220
|
+
|
|
221
|
+
await this.markEventAsFailed(item.id, newRetryCount, error)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private parseOccurredAt(occurredAt: unknown): Date {
|
|
227
|
+
if (occurredAt instanceof Date) {
|
|
228
|
+
return occurredAt
|
|
229
|
+
}
|
|
230
|
+
if (typeof occurredAt === "string") {
|
|
231
|
+
return new Date(occurredAt)
|
|
232
|
+
}
|
|
233
|
+
return new Date()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async markEventAsProcessing(id: string, now: number): Promise<void> {
|
|
237
|
+
await this.docClient.send(
|
|
238
|
+
new UpdateCommand({
|
|
239
|
+
TableName: this.config.tableName,
|
|
240
|
+
Key: { id },
|
|
241
|
+
UpdateExpression: "SET #status = :processing, gsiSortKey = :timeoutAt, startedOn = :now",
|
|
242
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
243
|
+
ExpressionAttributeValues: {
|
|
244
|
+
":processing": EventStatus.ACTIVE,
|
|
245
|
+
":timeoutAt": now + this.config.processingTimeoutMs,
|
|
246
|
+
":now": now,
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async markEventAsCompleted(id: string): Promise<void> {
|
|
253
|
+
await this.docClient.send(
|
|
254
|
+
new UpdateCommand({
|
|
255
|
+
TableName: this.config.tableName,
|
|
256
|
+
Key: { id },
|
|
257
|
+
UpdateExpression: "SET #status = :completed, completedOn = :now REMOVE gsiSortKey",
|
|
258
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
259
|
+
ExpressionAttributeValues: {
|
|
260
|
+
":completed": EventStatus.COMPLETED,
|
|
261
|
+
":now": Date.now(),
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async markEventAsFailed(id: string, retryCount: number, error: unknown): Promise<void> {
|
|
268
|
+
const isFinalFailure = retryCount >= this.config.maxRetries
|
|
269
|
+
const status = isFinalFailure ? EventStatus.FAILED : EventStatus.CREATED
|
|
270
|
+
const errorMsg = formatErrorMessage(error)
|
|
271
|
+
const now = Date.now()
|
|
272
|
+
|
|
273
|
+
const updateExpression = isFinalFailure
|
|
274
|
+
? "SET #status = :status, retryCount = :rc, lastError = :err, nextRetryAt = :now REMOVE gsiSortKey"
|
|
275
|
+
: "SET #status = :status, retryCount = :rc, lastError = :err, gsiSortKey = :nextAttempt"
|
|
276
|
+
|
|
277
|
+
const expressionAttributeValues: Record<string, any> = {
|
|
278
|
+
":status": status,
|
|
279
|
+
":rc": retryCount,
|
|
280
|
+
":err": errorMsg,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (isFinalFailure) {
|
|
284
|
+
expressionAttributeValues[":now"] = now
|
|
285
|
+
} else {
|
|
286
|
+
const delay = this.poller.calculateBackoff(retryCount)
|
|
287
|
+
expressionAttributeValues[":nextAttempt"] = now + delay
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await this.docClient.send(
|
|
291
|
+
new UpdateCommand({
|
|
292
|
+
TableName: this.config.tableName,
|
|
293
|
+
Key: { id },
|
|
294
|
+
UpdateExpression: updateExpression,
|
|
295
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
296
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
297
|
+
})
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async recoverStuckEvents() {
|
|
302
|
+
const now = Date.now()
|
|
303
|
+
|
|
304
|
+
const result = await this.docClient.send(
|
|
305
|
+
new DocQueryCommand({
|
|
306
|
+
TableName: this.config.tableName,
|
|
307
|
+
IndexName: this.config.statusIndexName,
|
|
308
|
+
KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
|
|
309
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
310
|
+
ExpressionAttributeValues: {
|
|
311
|
+
":status": EventStatus.ACTIVE,
|
|
312
|
+
":now": now,
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if (result.Items && result.Items.length > 0) {
|
|
318
|
+
await Promise.all(
|
|
319
|
+
result.Items.map((item) =>
|
|
320
|
+
this.markEventAsFailed(item.id, (item.retryCount || 0) + 1, "Processing timeout")
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
package/src/index.ts
ADDED