@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.
@@ -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
@@ -0,0 +1,2 @@
1
+ export * from "./dynamodb-aws-sdk-outbox"
2
+ export * from "./transaction-storage"