@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
package/README.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# DynamoDB AWS SDK Outbox Adapter
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
> **Reliable, Serverless-First Event Storage for DynamoDB**
|
|
8
|
+
|
|
9
|
+
The DynamoDB adapter for `outbox-event-bus` provides a high-performance, resilient outbox implementation designed specifically for AWS environments. It leverages DynamoDB's native scalability, TTL features, and Global Secondary Indices (GSIs) to ensure zero event loss even under intense load.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## How it Works
|
|
14
|
+
|
|
15
|
+
The adapter persists events in a DynamoDB table and uses a Global Secondary Index (GSI) to efficiently track event progression.
|
|
16
|
+
|
|
17
|
+
```mermaid
|
|
18
|
+
graph LR
|
|
19
|
+
A[emit] --> B{DynamoDB Table}
|
|
20
|
+
B -- Pending --> C[Poller]
|
|
21
|
+
C -- Claim --> D[Processing]
|
|
22
|
+
D -- Success --> E[Completed / Removed]
|
|
23
|
+
D -- Error --> F[Retry / Failed]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Why DynamoDB?
|
|
27
|
+
- **Zero Connection Pools**: Unlike RDS, DynamoDB handles HTTP connections natively—perfect for AWS Lambda.
|
|
28
|
+
- **Auto-Scaling**: Seamlessly scales from 0 to millions of events without managing IOPS.
|
|
29
|
+
- **Fine-Grained IAM**: Security-first approach with native AWS authentication.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### 1. Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @outbox-event-bus/dynamodb-aws-sdk-outbox
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Prepare Your Table
|
|
42
|
+
|
|
43
|
+
Create a DynamoDB table with an `id` partition key and a GSI for status tracking.
|
|
44
|
+
|
|
45
|
+
| Component | Attribute | Type | Detail |
|
|
46
|
+
| :--- | :--- | :--- | :--- |
|
|
47
|
+
| **Table PK** | `id` | String | Unique Event ID |
|
|
48
|
+
| **GSI PK** | `status` | String | `created`, `active`, etc. |
|
|
49
|
+
| **GSI SK** | `gsiSortKey` | Number | Unix timestamp for ordering |
|
|
50
|
+
|
|
51
|
+
### 3. Initialize & Start
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
55
|
+
import { DynamoDBAwsSdkOutbox } from '@outbox-event-bus/dynamodb-aws-sdk-outbox';
|
|
56
|
+
import { OutboxEventBus } from 'outbox-event-bus';
|
|
57
|
+
|
|
58
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
59
|
+
client: new DynamoDBClient({ region: 'us-east-1' }),
|
|
60
|
+
tableName: 'my-events-table',
|
|
61
|
+
statusIndexName: 'status-gsiSortKey-index' // Name of your GSI
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
|
|
65
|
+
const event = error.context?.event;
|
|
66
|
+
console.error('Event failed:', event?.type, error);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
bus.start();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Feature Highlights
|
|
75
|
+
|
|
76
|
+
- **Atomic Transactions**: Emit events and update your business data in a single atomic operation.
|
|
77
|
+
- **Optimistic Locking**: Guarantees "at-least-once" delivery without double-processing, even with multiple workers.
|
|
78
|
+
- **Zombie Recovery**: Automatically detects and restarts events that got stuck due to Lambda timeouts or crashes.
|
|
79
|
+
- **Batch Processing**: Groups event updates into efficient `BatchWriteItem` requests.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Concurrency & Locking
|
|
84
|
+
|
|
85
|
+
This adapter uses **Optimistic Locking** via Conditional Writes to ensure safe concurrent processing.
|
|
86
|
+
|
|
87
|
+
- **Atomic Claims**: The adapter uses `ConditionExpression` (e.g., `attribute_not_exists(locked_by)`) to claim events.
|
|
88
|
+
- **Multiple Workers**: You can safely run multiple instances (e.g., Lambda functions).
|
|
89
|
+
- **No Duplicates**: DynamoDB guarantees that only one worker can successfully claim a specific event.
|
|
90
|
+
|
|
91
|
+
## How-to Guides
|
|
92
|
+
|
|
93
|
+
### Transactional Writes (AsyncLocalStorage)
|
|
94
|
+
|
|
95
|
+
The recommended way to use the outbox is with `AsyncLocalStorage`. This allows you to collect multiple database operations and an event into a single atomic transaction.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
|
|
99
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
100
|
+
|
|
101
|
+
const als = new AsyncLocalStorage<any>();
|
|
102
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
103
|
+
client,
|
|
104
|
+
tableName: 'events',
|
|
105
|
+
getCollector: () => als.getStore() // Link the outbox to the current store
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
async function createUser(user: any) {
|
|
109
|
+
const transactionItems: any[] = [];
|
|
110
|
+
|
|
111
|
+
await als.run({ push: (item) => transactionItems.push(item) }, async () => {
|
|
112
|
+
// 1. Business Logic
|
|
113
|
+
transactionItems.push({ Put: { TableName: 'Users', Item: user } });
|
|
114
|
+
|
|
115
|
+
// 2. Emit Event (automatically attached to transactionItems via collector)
|
|
116
|
+
await bus.emit({ id: '...', type: 'user.created', payload: user });
|
|
117
|
+
|
|
118
|
+
// 3. Commit
|
|
119
|
+
await client.send(new TransactWriteCommand({ TransactItems: transactionItems }));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Manual Transaction Collection
|
|
125
|
+
|
|
126
|
+
If you prefer explicit over implicit, you can pass a collection array directly.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const items: any[] = [];
|
|
130
|
+
items.push({ Put: { TableName: 'Data', Item: { id: 1 } } });
|
|
131
|
+
|
|
132
|
+
// The second argument is the collector
|
|
133
|
+
await bus.emit({ type: 'item.updated', payload: { id: 1 } }, items);
|
|
134
|
+
|
|
135
|
+
await client.send(new TransactWriteCommand({ TransactItems: items }));
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Configuration Reference
|
|
141
|
+
|
|
142
|
+
### `DynamoDBAwsSdkOutboxConfig`
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
interface DynamoDBAwsSdkOutboxConfig extends OutboxConfig {
|
|
146
|
+
// DynamoDB-specific options
|
|
147
|
+
client: DynamoDBClient; // AWS SDK v3 Client instance
|
|
148
|
+
tableName: string; // DynamoDB table name
|
|
149
|
+
statusIndexName?: string; // GSI name (default: 'status-gsiSortKey-index')
|
|
150
|
+
getCollector?: () => any[] | undefined; // Transaction collector getter
|
|
151
|
+
// Inherited from OutboxConfig
|
|
152
|
+
batchSize?: number; // Events per poll (default: 50)
|
|
153
|
+
pollIntervalMs?: number; // Polling interval (default: 1000ms)
|
|
154
|
+
processingTimeoutMs?: number; // Processing timeout (default: 30000ms)
|
|
155
|
+
maxRetries?: number; // Max retry attempts (default: 5)
|
|
156
|
+
baseBackoffMs?: number; // Base backoff delay (default: 1000ms)
|
|
157
|
+
maxErrorBackoffMs?: number; // Max polling error backoff (default: 30000ms)
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> [!NOTE]
|
|
162
|
+
> All adapters inherit base configuration options from `OutboxConfig`. See the [API Reference](https://github.com/dunika/outbox-event-bus/blob/main/docs/API_REFERENCE.md#base-outbox-configuration) for details on inherited options.
|
|
163
|
+
|
|
164
|
+
| Option | Type | Default | Description |
|
|
165
|
+
| :--- | :--- | :--- | :--- |
|
|
166
|
+
| `client` | `DynamoDBClient` | **Required** | AWS SDK v3 Client instance. |
|
|
167
|
+
| `tableName` | `string` | **Required** | The DynamoDB table name. |
|
|
168
|
+
| `statusIndexName` | `string` | `'status-gsiSortKey-index'` | The name of the GSI used for polling. |
|
|
169
|
+
| `getCollector` | `() => any[] \| undefined` | - | Transaction collector getter for atomic writes. |
|
|
170
|
+
| `batchSize` | `number` | `50` | Number of events to claim per poll. |
|
|
171
|
+
| `pollIntervalMs` | `number` | `1000` | Delay between poll cycles. |
|
|
172
|
+
| `processingTimeoutMs`| `number` | `30000` | After this time, a 'PROCESSING' event is considered stuck. |
|
|
173
|
+
| `maxRetries` | `number` | `5` | Maximum attempts for a failed event. |
|
|
174
|
+
| `baseBackoffMs` | `number` | `1000` | Base delay for exponential backoff. |
|
|
175
|
+
| `maxErrorBackoffMs` | `number` | `30000` | Maximum backoff delay after polling errors. |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## API Reference
|
|
180
|
+
|
|
181
|
+
### `DynamoDBAwsSdkOutbox`
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
class DynamoDBAwsSdkOutbox implements IOutbox<any[]>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### Constructor
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
constructor(config: DynamoDBAwsSdkOutboxConfig)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Creates a new DynamoDB outbox adapter using AWS SDK v3.
|
|
194
|
+
|
|
195
|
+
**Parameters:**
|
|
196
|
+
- `config`: Configuration object (see [Configuration Reference](#configuration-reference))
|
|
197
|
+
|
|
198
|
+
**Example:**
|
|
199
|
+
```typescript
|
|
200
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
201
|
+
client: new DynamoDBClient({ region: 'us-east-1' }),
|
|
202
|
+
tableName: 'my-events-table',
|
|
203
|
+
statusIndexName: 'status-index',
|
|
204
|
+
batchSize: 100
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
#### Methods
|
|
211
|
+
|
|
212
|
+
##### `publish(events: BusEvent[], transaction?: any[]): Promise<void>`
|
|
213
|
+
|
|
214
|
+
Publishes events to the outbox. If a `transaction` collector array is provided, events are added to it for atomic writes. Otherwise, events are written immediately.
|
|
215
|
+
|
|
216
|
+
**Parameters:**
|
|
217
|
+
- `events`: Array of events to publish
|
|
218
|
+
- `transaction`: Optional collector array for TransactWriteCommand
|
|
219
|
+
|
|
220
|
+
**Example:**
|
|
221
|
+
```typescript
|
|
222
|
+
// Direct publish
|
|
223
|
+
await outbox.publish([
|
|
224
|
+
{ id: '1', type: 'user.created', payload: user, occurredAt: new Date() }
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
// With transaction collector
|
|
228
|
+
const items: any[] = [];
|
|
229
|
+
await outbox.publish([event], items);
|
|
230
|
+
await client.send(new TransactWriteCommand({ TransactItems: items }));
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
##### `getFailedEvents(): Promise<FailedBusEvent[]>`
|
|
236
|
+
|
|
237
|
+
Retrieves up to 100 failed events, ordered by occurrence time (newest first).
|
|
238
|
+
|
|
239
|
+
**Returns:** Array of `FailedBusEvent` objects with error details and retry count.
|
|
240
|
+
|
|
241
|
+
**Example:**
|
|
242
|
+
```typescript
|
|
243
|
+
const failed = await outbox.getFailedEvents();
|
|
244
|
+
for (const event of failed) {
|
|
245
|
+
console.log(`Event ${event.id} failed ${event.retryCount} times`);
|
|
246
|
+
console.log(`Last error: ${event.error}`);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
##### `retryEvents(eventIds: string[]): Promise<void>`
|
|
253
|
+
|
|
254
|
+
Resets failed events to `created` status for retry. Clears retry count, error message, and next retry timestamp.
|
|
255
|
+
|
|
256
|
+
**Parameters:**
|
|
257
|
+
- `eventIds`: Array of event IDs to retry
|
|
258
|
+
|
|
259
|
+
**Example:**
|
|
260
|
+
```typescript
|
|
261
|
+
const failed = await outbox.getFailedEvents();
|
|
262
|
+
await outbox.retryEvents(failed.map(e => e.id));
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
##### `start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void`
|
|
268
|
+
|
|
269
|
+
Starts the polling service to process events.
|
|
270
|
+
|
|
271
|
+
**Parameters:**
|
|
272
|
+
- `handler`: Async function to process each event
|
|
273
|
+
- `onError`: Error handler called when event processing fails
|
|
274
|
+
|
|
275
|
+
**Example:**
|
|
276
|
+
```typescript
|
|
277
|
+
outbox.start(
|
|
278
|
+
async (event) => {
|
|
279
|
+
console.log('Processing:', event);
|
|
280
|
+
// Your event handling logic
|
|
281
|
+
},
|
|
282
|
+
(err, event) => {
|
|
283
|
+
console.error('Failed to process event:', err, event);
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
##### `stop(): Promise<void>`
|
|
291
|
+
|
|
292
|
+
Stops the polling service gracefully and waits for in-flight events to complete.
|
|
293
|
+
|
|
294
|
+
**Example:**
|
|
295
|
+
```typescript
|
|
296
|
+
await outbox.stop();
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## IAM Permissions
|
|
302
|
+
|
|
303
|
+
Your application's IAM role needs the following permissions:
|
|
304
|
+
|
|
305
|
+
```json
|
|
306
|
+
{
|
|
307
|
+
"Version": "2012-10-17",
|
|
308
|
+
"Statement": [
|
|
309
|
+
{
|
|
310
|
+
"Effect": "Allow",
|
|
311
|
+
"Action": ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
|
|
312
|
+
"Resource": "arn:aws:dynamodb:*:*:table/YOUR_TABLE/index/YOUR_GSI"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"Effect": "Allow",
|
|
316
|
+
"Action": ["dynamodb:Query"],
|
|
317
|
+
"Resource": "arn:aws:dynamodb:*:*:table/YOUR_TABLE/index/YOUR_GSI"
|
|
318
|
+
}
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Troubleshooting
|
|
326
|
+
|
|
327
|
+
### Events Not Moving
|
|
328
|
+
|
|
329
|
+
**Symptom**: Events are stuck in `created` status and never get processed.
|
|
330
|
+
|
|
331
|
+
**Causes**:
|
|
332
|
+
1. `statusIndexName` doesn't match the actual GSI name in DynamoDB
|
|
333
|
+
2. GSI not created or still being built
|
|
334
|
+
3. Polling service not started
|
|
335
|
+
|
|
336
|
+
**Solution**:
|
|
337
|
+
```typescript
|
|
338
|
+
// 1. Verify GSI name matches
|
|
339
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
340
|
+
client,
|
|
341
|
+
tableName: 'my-events',
|
|
342
|
+
statusIndexName: 'status-gsiSortKey-index' // Must match actual GSI name
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// 2. Check GSI status in AWS Console or CLI
|
|
346
|
+
// aws dynamodb describe-table --table-name my-events
|
|
347
|
+
|
|
348
|
+
// 3. Ensure bus is started
|
|
349
|
+
bus.start();
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
### ConditionalCheckFailedException
|
|
355
|
+
|
|
356
|
+
**Symptom**: Seeing `ConditionalCheckFailedException` errors in logs.
|
|
357
|
+
|
|
358
|
+
**Cause**: Multiple workers trying to claim the same event simultaneously (expected behavior).
|
|
359
|
+
|
|
360
|
+
**Solution**: This is **normal and expected**! The adapter uses optimistic locking to prevent duplicate processing. The error means another worker successfully claimed the event first. No action needed—the adapter handles this gracefully.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
### Slow Polling / High Latency
|
|
365
|
+
|
|
366
|
+
**Symptom**: Events take a long time to process after being emitted.
|
|
367
|
+
|
|
368
|
+
**Causes**:
|
|
369
|
+
1. Low Read Capacity Units (RCU) on the GSI
|
|
370
|
+
2. Small `batchSize` setting
|
|
371
|
+
3. Long `pollIntervalMs`
|
|
372
|
+
|
|
373
|
+
**Solution**:
|
|
374
|
+
```typescript
|
|
375
|
+
// 1. Increase batch size for higher throughput
|
|
376
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
377
|
+
client,
|
|
378
|
+
tableName: 'events',
|
|
379
|
+
batchSize: 100, // Process more events per poll (default: 50)
|
|
380
|
+
pollIntervalMs: 500 // Poll more frequently (default: 1000ms)
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// 2. Check and increase RCU on your GSI in AWS Console
|
|
384
|
+
// Recommended: Use On-Demand billing mode for auto-scaling
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
### Duplicate Event Processing
|
|
390
|
+
|
|
391
|
+
**Symptom**: Event handlers are being called multiple times for the same event.
|
|
392
|
+
|
|
393
|
+
**Cause**: The outbox guarantees "at-least-once" delivery. Duplicates can occur due to:
|
|
394
|
+
- Worker crashes during processing
|
|
395
|
+
- Network timeouts
|
|
396
|
+
- Publisher retries
|
|
397
|
+
|
|
398
|
+
**Solution**: Make your event handlers **idempotent**:
|
|
399
|
+
```typescript
|
|
400
|
+
bus.on('order.created', async (event) => {
|
|
401
|
+
// Use event ID for deduplication
|
|
402
|
+
const existing = await db.getOrder(event.payload.orderId);
|
|
403
|
+
if (existing) {
|
|
404
|
+
console.log('Order already processed, skipping');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await db.createOrder(event.payload);
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### ProvisionedThroughputExceededException
|
|
415
|
+
|
|
416
|
+
**Symptom**: `ProvisionedThroughputExceededException` errors during high load.
|
|
417
|
+
|
|
418
|
+
**Cause**: Write or read capacity exceeded on table or GSI.
|
|
419
|
+
|
|
420
|
+
**Solution**:
|
|
421
|
+
```typescript
|
|
422
|
+
// Option 1: Switch to On-Demand billing mode (recommended)
|
|
423
|
+
// aws dynamodb update-table --table-name events --billing-mode PAY_PER_REQUEST
|
|
424
|
+
|
|
425
|
+
// Option 2: Increase provisioned capacity
|
|
426
|
+
// aws dynamodb update-table --table-name events \
|
|
427
|
+
// --provisioned-throughput ReadCapacityUnits=100,WriteCapacityUnits=100
|
|
428
|
+
|
|
429
|
+
// Option 3: Reduce polling frequency temporarily
|
|
430
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
431
|
+
client,
|
|
432
|
+
tableName: 'events',
|
|
433
|
+
pollIntervalMs: 2000, // Reduce polling frequency
|
|
434
|
+
batchSize: 25 // Reduce batch size
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### Events Stuck in active
|
|
441
|
+
|
|
442
|
+
**Symptom**: Events remain in `active` status indefinitely.
|
|
443
|
+
|
|
444
|
+
**Cause**: Worker crashed or Lambda timed out during processing.
|
|
445
|
+
|
|
446
|
+
**Solution**: The adapter automatically recovers stuck events based on `processingTimeoutMs`. To force immediate recovery:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// Stuck events are automatically reclaimed after processingTimeoutMs
|
|
450
|
+
const outbox = new DynamoDBAwsSdkOutbox({
|
|
451
|
+
client,
|
|
452
|
+
tableName: 'events',
|
|
453
|
+
processingTimeoutMs: 30000 // Adjust based on handler complexity (default: 30s)
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Manual recovery: Query and reset stuck events
|
|
457
|
+
const response = await client.send(new QueryCommand({
|
|
458
|
+
TableName: 'events',
|
|
459
|
+
IndexName: 'status-gsiSortKey-index',
|
|
460
|
+
KeyConditionExpression: "#status = :active AND gsiSortKey <= :now",
|
|
461
|
+
ExpressionAttributeNames: {
|
|
462
|
+
"#status": "status"
|
|
463
|
+
},
|
|
464
|
+
ExpressionAttributeValues: {
|
|
465
|
+
":active": { S: 'active' },
|
|
466
|
+
":now": { N: String(Date.now()) }
|
|
467
|
+
}
|
|
468
|
+
}));
|
|
469
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
let _aws_sdk_client_dynamodb = require("@aws-sdk/client-dynamodb");
|
|
2
|
+
let _aws_sdk_lib_dynamodb = require("@aws-sdk/lib-dynamodb");
|
|
3
|
+
let outbox_event_bus = require("outbox-event-bus");
|
|
4
|
+
let node_async_hooks = require("node:async_hooks");
|
|
5
|
+
|
|
6
|
+
//#region src/dynamodb-aws-sdk-outbox.ts
|
|
7
|
+
const DYNAMODB_TRANSACTION_LIMIT = 100;
|
|
8
|
+
var DynamoDBAwsSdkOutbox = class {
|
|
9
|
+
config;
|
|
10
|
+
docClient;
|
|
11
|
+
poller;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = {
|
|
14
|
+
batchSize: config.batchSize ?? 50,
|
|
15
|
+
pollIntervalMs: config.pollIntervalMs ?? 1e3,
|
|
16
|
+
maxRetries: config.maxRetries ?? 5,
|
|
17
|
+
baseBackoffMs: config.baseBackoffMs ?? 1e3,
|
|
18
|
+
processingTimeoutMs: config.processingTimeoutMs ?? 3e4,
|
|
19
|
+
maxErrorBackoffMs: config.maxErrorBackoffMs ?? 3e4,
|
|
20
|
+
tableName: config.tableName,
|
|
21
|
+
statusIndexName: config.statusIndexName ?? "status-gsiSortKey-index",
|
|
22
|
+
client: config.client,
|
|
23
|
+
getCollector: config.getCollector
|
|
24
|
+
};
|
|
25
|
+
this.docClient = _aws_sdk_lib_dynamodb.DynamoDBDocumentClient.from(config.client, { marshallOptions: { removeUndefinedValues: true } });
|
|
26
|
+
this.poller = new outbox_event_bus.PollingService({
|
|
27
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
28
|
+
baseBackoffMs: this.config.baseBackoffMs,
|
|
29
|
+
maxErrorBackoffMs: this.config.maxErrorBackoffMs,
|
|
30
|
+
performMaintenance: () => this.recoverStuckEvents(),
|
|
31
|
+
processBatch: (handler) => this.processBatch(handler)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async publish(events, transaction) {
|
|
35
|
+
if (events.length === 0) return;
|
|
36
|
+
if (events.length > DYNAMODB_TRANSACTION_LIMIT) throw new outbox_event_bus.BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, events.length);
|
|
37
|
+
const collector = transaction ?? this.config.getCollector?.();
|
|
38
|
+
const items = events.map((event) => {
|
|
39
|
+
return { Put: {
|
|
40
|
+
TableName: this.config.tableName,
|
|
41
|
+
Item: {
|
|
42
|
+
id: event.id,
|
|
43
|
+
type: event.type,
|
|
44
|
+
payload: event.payload,
|
|
45
|
+
occurredAt: event.occurredAt.toISOString(),
|
|
46
|
+
status: outbox_event_bus.EventStatus.CREATED,
|
|
47
|
+
retryCount: 0,
|
|
48
|
+
gsiSortKey: event.occurredAt.getTime()
|
|
49
|
+
}
|
|
50
|
+
} };
|
|
51
|
+
});
|
|
52
|
+
if (collector) {
|
|
53
|
+
const itemsInCollector = collector.items?.length ?? 0;
|
|
54
|
+
if (itemsInCollector + items.length > DYNAMODB_TRANSACTION_LIMIT) throw new outbox_event_bus.BatchSizeLimitError(DYNAMODB_TRANSACTION_LIMIT, itemsInCollector + items.length);
|
|
55
|
+
for (const item of items) collector.push(item);
|
|
56
|
+
} else await this.docClient.send(new _aws_sdk_lib_dynamodb.TransactWriteCommand({ TransactItems: items }));
|
|
57
|
+
}
|
|
58
|
+
async getFailedEvents() {
|
|
59
|
+
const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
|
|
60
|
+
TableName: this.config.tableName,
|
|
61
|
+
IndexName: this.config.statusIndexName,
|
|
62
|
+
KeyConditionExpression: "#status = :status",
|
|
63
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
64
|
+
ExpressionAttributeValues: { ":status": outbox_event_bus.EventStatus.FAILED },
|
|
65
|
+
Limit: 100,
|
|
66
|
+
ScanIndexForward: false
|
|
67
|
+
}));
|
|
68
|
+
if (!result.Items) return [];
|
|
69
|
+
return result.Items.map((item) => {
|
|
70
|
+
const event = {
|
|
71
|
+
id: item.id,
|
|
72
|
+
type: item.type,
|
|
73
|
+
payload: item.payload,
|
|
74
|
+
occurredAt: this.parseOccurredAt(item.occurredAt),
|
|
75
|
+
retryCount: item.retryCount || 0
|
|
76
|
+
};
|
|
77
|
+
if (item.lastError) event.error = item.lastError;
|
|
78
|
+
if (item.startedOn) event.lastAttemptAt = new Date(item.startedOn);
|
|
79
|
+
return event;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async retryEvents(eventIds) {
|
|
83
|
+
if (eventIds.length === 0) return;
|
|
84
|
+
await Promise.all(eventIds.map((id) => this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
|
|
85
|
+
TableName: this.config.tableName,
|
|
86
|
+
Key: { id },
|
|
87
|
+
UpdateExpression: "SET #status = :pending, retryCount = :zero, gsiSortKey = :now REMOVE lastError, nextRetryAt, startedOn",
|
|
88
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
89
|
+
ExpressionAttributeValues: {
|
|
90
|
+
":pending": outbox_event_bus.EventStatus.CREATED,
|
|
91
|
+
":zero": 0,
|
|
92
|
+
":now": Date.now()
|
|
93
|
+
}
|
|
94
|
+
}))));
|
|
95
|
+
}
|
|
96
|
+
start(handler, onError) {
|
|
97
|
+
this.poller.start(handler, onError);
|
|
98
|
+
}
|
|
99
|
+
async stop() {
|
|
100
|
+
await this.poller.stop();
|
|
101
|
+
}
|
|
102
|
+
async processBatch(handler) {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
|
|
105
|
+
TableName: this.config.tableName,
|
|
106
|
+
IndexName: this.config.statusIndexName,
|
|
107
|
+
KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
|
|
108
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
109
|
+
ExpressionAttributeValues: {
|
|
110
|
+
":status": outbox_event_bus.EventStatus.CREATED,
|
|
111
|
+
":now": now
|
|
112
|
+
},
|
|
113
|
+
Limit: this.config.batchSize
|
|
114
|
+
}));
|
|
115
|
+
if (!result.Items || result.Items.length === 0) return;
|
|
116
|
+
for (const item of result.Items) {
|
|
117
|
+
const event = {
|
|
118
|
+
id: item.id,
|
|
119
|
+
type: item.type,
|
|
120
|
+
payload: item.payload,
|
|
121
|
+
occurredAt: this.parseOccurredAt(item.occurredAt)
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
await this.markEventAsProcessing(item.id, now);
|
|
125
|
+
await handler(event);
|
|
126
|
+
await this.markEventAsCompleted(event.id);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof _aws_sdk_client_dynamodb.ConditionalCheckFailedException) continue;
|
|
129
|
+
const newRetryCount = (item.retryCount || 0) + 1;
|
|
130
|
+
(0, outbox_event_bus.reportEventError)(this.poller.onError, error, event, newRetryCount, this.config.maxRetries);
|
|
131
|
+
await this.markEventAsFailed(item.id, newRetryCount, error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
parseOccurredAt(occurredAt) {
|
|
136
|
+
if (occurredAt instanceof Date) return occurredAt;
|
|
137
|
+
if (typeof occurredAt === "string") return new Date(occurredAt);
|
|
138
|
+
return /* @__PURE__ */ new Date();
|
|
139
|
+
}
|
|
140
|
+
async markEventAsProcessing(id, now) {
|
|
141
|
+
await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
|
|
142
|
+
TableName: this.config.tableName,
|
|
143
|
+
Key: { id },
|
|
144
|
+
UpdateExpression: "SET #status = :processing, gsiSortKey = :timeoutAt, startedOn = :now",
|
|
145
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
146
|
+
ExpressionAttributeValues: {
|
|
147
|
+
":processing": outbox_event_bus.EventStatus.ACTIVE,
|
|
148
|
+
":timeoutAt": now + this.config.processingTimeoutMs,
|
|
149
|
+
":now": now
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
async markEventAsCompleted(id) {
|
|
154
|
+
await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
|
|
155
|
+
TableName: this.config.tableName,
|
|
156
|
+
Key: { id },
|
|
157
|
+
UpdateExpression: "SET #status = :completed, completedOn = :now REMOVE gsiSortKey",
|
|
158
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
159
|
+
ExpressionAttributeValues: {
|
|
160
|
+
":completed": outbox_event_bus.EventStatus.COMPLETED,
|
|
161
|
+
":now": Date.now()
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
async markEventAsFailed(id, retryCount, error) {
|
|
166
|
+
const isFinalFailure = retryCount >= this.config.maxRetries;
|
|
167
|
+
const status = isFinalFailure ? outbox_event_bus.EventStatus.FAILED : outbox_event_bus.EventStatus.CREATED;
|
|
168
|
+
const errorMsg = (0, outbox_event_bus.formatErrorMessage)(error);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const updateExpression = isFinalFailure ? "SET #status = :status, retryCount = :rc, lastError = :err, nextRetryAt = :now REMOVE gsiSortKey" : "SET #status = :status, retryCount = :rc, lastError = :err, gsiSortKey = :nextAttempt";
|
|
171
|
+
const expressionAttributeValues = {
|
|
172
|
+
":status": status,
|
|
173
|
+
":rc": retryCount,
|
|
174
|
+
":err": errorMsg
|
|
175
|
+
};
|
|
176
|
+
if (isFinalFailure) expressionAttributeValues[":now"] = now;
|
|
177
|
+
else expressionAttributeValues[":nextAttempt"] = now + this.poller.calculateBackoff(retryCount);
|
|
178
|
+
await this.docClient.send(new _aws_sdk_lib_dynamodb.UpdateCommand({
|
|
179
|
+
TableName: this.config.tableName,
|
|
180
|
+
Key: { id },
|
|
181
|
+
UpdateExpression: updateExpression,
|
|
182
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
183
|
+
ExpressionAttributeValues: expressionAttributeValues
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
async recoverStuckEvents() {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const result = await this.docClient.send(new _aws_sdk_lib_dynamodb.QueryCommand({
|
|
189
|
+
TableName: this.config.tableName,
|
|
190
|
+
IndexName: this.config.statusIndexName,
|
|
191
|
+
KeyConditionExpression: "#status = :status AND gsiSortKey <= :now",
|
|
192
|
+
ExpressionAttributeNames: { "#status": "status" },
|
|
193
|
+
ExpressionAttributeValues: {
|
|
194
|
+
":status": outbox_event_bus.EventStatus.ACTIVE,
|
|
195
|
+
":now": now
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
if (result.Items && result.Items.length > 0) await Promise.all(result.Items.map((item) => this.markEventAsFailed(item.id, (item.retryCount || 0) + 1, "Processing timeout")));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/transaction-storage.ts
|
|
204
|
+
const dynamodbAwsSdkTransactionStorage = new node_async_hooks.AsyncLocalStorage();
|
|
205
|
+
async function withDynamoDBAwsSdkTransaction(fn) {
|
|
206
|
+
const items = [];
|
|
207
|
+
const collector = {
|
|
208
|
+
push: (item) => items.push(item),
|
|
209
|
+
get items() {
|
|
210
|
+
return items;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return dynamodbAwsSdkTransactionStorage.run(collector, () => fn(collector));
|
|
214
|
+
}
|
|
215
|
+
function getDynamoDBAwsSdkCollector() {
|
|
216
|
+
return () => dynamodbAwsSdkTransactionStorage.getStore();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
exports.DynamoDBAwsSdkOutbox = DynamoDBAwsSdkOutbox;
|
|
221
|
+
exports.dynamodbAwsSdkTransactionStorage = dynamodbAwsSdkTransactionStorage;
|
|
222
|
+
exports.getDynamoDBAwsSdkCollector = getDynamoDBAwsSdkCollector;
|
|
223
|
+
exports.withDynamoDBAwsSdkTransaction = withDynamoDBAwsSdkTransaction;
|
|
224
|
+
//# sourceMappingURL=index.cjs.map
|