@signaltree/events 7.3.6 → 7.4.1
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/dist/angular/handlers.cjs +38 -0
- package/dist/angular/handlers.js +35 -0
- package/dist/angular/index.cjs +15 -0
- package/dist/angular/index.js +3 -0
- package/dist/angular/optimistic-updates.cjs +161 -0
- package/dist/angular/optimistic-updates.js +159 -0
- package/{angular.cjs.js → dist/angular/websocket.service.cjs} +0 -194
- package/{angular.esm.js → dist/angular/websocket.service.js} +1 -191
- package/dist/core/error-classification.cjs +282 -0
- package/dist/core/error-classification.js +276 -0
- package/{factory.cjs.js → dist/core/factory.cjs} +3 -40
- package/{factory.esm.js → dist/core/factory.js} +2 -37
- package/dist/core/idempotency.cjs +252 -0
- package/dist/core/idempotency.js +247 -0
- package/dist/core/registry.cjs +183 -0
- package/dist/core/registry.js +180 -0
- package/dist/core/types.cjs +41 -0
- package/dist/core/types.js +38 -0
- package/{index.cjs.js → dist/core/validation.cjs} +1 -23
- package/{index.esm.js → dist/core/validation.js} +1 -4
- package/dist/index.cjs +43 -0
- package/dist/index.js +7 -0
- package/dist/nestjs/base.subscriber.cjs +287 -0
- package/dist/nestjs/base.subscriber.js +287 -0
- package/dist/nestjs/decorators.cjs +35 -0
- package/dist/nestjs/decorators.js +32 -0
- package/dist/nestjs/dlq.service.cjs +249 -0
- package/dist/nestjs/dlq.service.js +249 -0
- package/dist/nestjs/event-bus.module.cjs +152 -0
- package/dist/nestjs/event-bus.module.js +152 -0
- package/dist/nestjs/event-bus.service.cjs +243 -0
- package/dist/nestjs/event-bus.service.js +243 -0
- package/dist/nestjs/index.cjs +33 -0
- package/dist/nestjs/index.js +6 -0
- package/dist/nestjs/tokens.cjs +14 -0
- package/dist/nestjs/tokens.js +9 -0
- package/dist/testing/assertions.cjs +172 -0
- package/dist/testing/assertions.js +169 -0
- package/dist/testing/factories.cjs +122 -0
- package/dist/testing/factories.js +119 -0
- package/dist/testing/helpers.cjs +233 -0
- package/dist/testing/helpers.js +227 -0
- package/dist/testing/index.cjs +20 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/mock-event-bus.cjs +237 -0
- package/dist/testing/mock-event-bus.js +234 -0
- package/package.json +35 -23
- package/angular.d.ts +0 -1
- package/idempotency.cjs.js +0 -713
- package/idempotency.esm.js +0 -701
- package/index.d.ts +0 -1
- package/nestjs.cjs.js +0 -951
- package/nestjs.d.ts +0 -1
- package/nestjs.esm.js +0 -944
- package/testing.cjs.js +0 -755
- package/testing.d.ts +0 -1
- package/testing.esm.js +0 -743
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var factory = require('./factory.cjs.js');
|
|
4
3
|
var zod = require('zod');
|
|
5
|
-
var idempotency = require('./idempotency.cjs.js');
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* Event validation using Zod schemas
|
|
@@ -174,27 +172,7 @@ function parseEvent(schema, event) {
|
|
|
174
172
|
return schema.safeParse(event);
|
|
175
173
|
}
|
|
176
174
|
|
|
177
|
-
exports.
|
|
178
|
-
exports.EVENT_PRIORITIES = factory.EVENT_PRIORITIES;
|
|
179
|
-
exports.createEvent = factory.createEvent;
|
|
180
|
-
exports.createEventFactory = factory.createEventFactory;
|
|
181
|
-
exports.generateCorrelationId = factory.generateCorrelationId;
|
|
182
|
-
exports.generateEventId = factory.generateEventId;
|
|
183
|
-
Object.defineProperty(exports, "z", {
|
|
184
|
-
enumerable: true,
|
|
185
|
-
get: function () { return zod.z; }
|
|
186
|
-
});
|
|
187
|
-
exports.DEFAULT_RETRY_CONFIGS = idempotency.DEFAULT_RETRY_CONFIGS;
|
|
188
|
-
exports.EventRegistry = idempotency.EventRegistry;
|
|
189
|
-
exports.InMemoryIdempotencyStore = idempotency.InMemoryIdempotencyStore;
|
|
190
|
-
exports.classifyError = idempotency.classifyError;
|
|
191
|
-
exports.createErrorClassifier = idempotency.createErrorClassifier;
|
|
192
|
-
exports.createEventRegistry = idempotency.createEventRegistry;
|
|
193
|
-
exports.createInMemoryIdempotencyStore = idempotency.createInMemoryIdempotencyStore;
|
|
194
|
-
exports.defaultErrorClassifier = idempotency.defaultErrorClassifier;
|
|
195
|
-
exports.generateCorrelationKey = idempotency.generateCorrelationKey;
|
|
196
|
-
exports.generateIdempotencyKey = idempotency.generateIdempotencyKey;
|
|
197
|
-
exports.isRetryableError = idempotency.isRetryableError;
|
|
175
|
+
exports.AggregateSchema = AggregateSchema;
|
|
198
176
|
exports.BaseEventSchema = BaseEventSchema;
|
|
199
177
|
exports.EventActorSchema = EventActorSchema;
|
|
200
178
|
exports.EventMetadataSchema = EventMetadataSchema;
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
export { D as DEFAULT_EVENT_VERSION, E as EVENT_PRIORITIES, c as createEvent, a as createEventFactory, b as generateCorrelationId, g as generateEventId } from './factory.esm.js';
|
|
2
1
|
import { z } from 'zod';
|
|
3
|
-
export { z } from 'zod';
|
|
4
|
-
export { D as DEFAULT_RETRY_CONFIGS, E as EventRegistry, I as InMemoryIdempotencyStore, a as classifyError, b as createErrorClassifier, c as createEventRegistry, e as createInMemoryIdempotencyStore, d as defaultErrorClassifier, f as generateCorrelationKey, g as generateIdempotencyKey, i as isRetryableError } from './idempotency.esm.js';
|
|
5
2
|
|
|
6
3
|
/**
|
|
7
4
|
* Event validation using Zod schemas
|
|
@@ -173,4 +170,4 @@ function parseEvent(schema, event) {
|
|
|
173
170
|
return schema.safeParse(event);
|
|
174
171
|
}
|
|
175
172
|
|
|
176
|
-
export { BaseEventSchema, EventActorSchema, EventMetadataSchema, EventValidationError, EventVersionSchema, createEventSchema, createEventSchemaFromZod, isValidEvent, parseEvent, validateEvent };
|
|
173
|
+
export { AggregateSchema, BaseEventSchema, EventActorSchema, EventMetadataSchema, EventValidationError, EventVersionSchema, createEventSchema, createEventSchemaFromZod, isValidEvent, parseEvent, validateEvent };
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var types = require('./core/types.cjs');
|
|
4
|
+
var validation = require('./core/validation.cjs');
|
|
5
|
+
var registry = require('./core/registry.cjs');
|
|
6
|
+
var factory = require('./core/factory.cjs');
|
|
7
|
+
var errorClassification = require('./core/error-classification.cjs');
|
|
8
|
+
var idempotency = require('./core/idempotency.cjs');
|
|
9
|
+
var zod = require('zod');
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
exports.DEFAULT_EVENT_VERSION = types.DEFAULT_EVENT_VERSION;
|
|
14
|
+
exports.EVENT_PRIORITIES = types.EVENT_PRIORITIES;
|
|
15
|
+
exports.BaseEventSchema = validation.BaseEventSchema;
|
|
16
|
+
exports.EventActorSchema = validation.EventActorSchema;
|
|
17
|
+
exports.EventMetadataSchema = validation.EventMetadataSchema;
|
|
18
|
+
exports.EventValidationError = validation.EventValidationError;
|
|
19
|
+
exports.EventVersionSchema = validation.EventVersionSchema;
|
|
20
|
+
exports.createEventSchema = validation.createEventSchema;
|
|
21
|
+
exports.createEventSchemaFromZod = validation.createEventSchemaFromZod;
|
|
22
|
+
exports.isValidEvent = validation.isValidEvent;
|
|
23
|
+
exports.parseEvent = validation.parseEvent;
|
|
24
|
+
exports.validateEvent = validation.validateEvent;
|
|
25
|
+
exports.EventRegistry = registry.EventRegistry;
|
|
26
|
+
exports.createEventRegistry = registry.createEventRegistry;
|
|
27
|
+
exports.createEvent = factory.createEvent;
|
|
28
|
+
exports.createEventFactory = factory.createEventFactory;
|
|
29
|
+
exports.generateCorrelationId = factory.generateCorrelationId;
|
|
30
|
+
exports.generateEventId = factory.generateEventId;
|
|
31
|
+
exports.DEFAULT_RETRY_CONFIGS = errorClassification.DEFAULT_RETRY_CONFIGS;
|
|
32
|
+
exports.classifyError = errorClassification.classifyError;
|
|
33
|
+
exports.createErrorClassifier = errorClassification.createErrorClassifier;
|
|
34
|
+
exports.defaultErrorClassifier = errorClassification.defaultErrorClassifier;
|
|
35
|
+
exports.isRetryableError = errorClassification.isRetryableError;
|
|
36
|
+
exports.InMemoryIdempotencyStore = idempotency.InMemoryIdempotencyStore;
|
|
37
|
+
exports.createInMemoryIdempotencyStore = idempotency.createInMemoryIdempotencyStore;
|
|
38
|
+
exports.generateCorrelationKey = idempotency.generateCorrelationKey;
|
|
39
|
+
exports.generateIdempotencyKey = idempotency.generateIdempotencyKey;
|
|
40
|
+
Object.defineProperty(exports, "z", {
|
|
41
|
+
enumerable: true,
|
|
42
|
+
get: function () { return zod.z; }
|
|
43
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DEFAULT_EVENT_VERSION, EVENT_PRIORITIES } from './core/types.js';
|
|
2
|
+
export { BaseEventSchema, EventActorSchema, EventMetadataSchema, EventValidationError, EventVersionSchema, createEventSchema, createEventSchemaFromZod, isValidEvent, parseEvent, validateEvent } from './core/validation.js';
|
|
3
|
+
export { EventRegistry, createEventRegistry } from './core/registry.js';
|
|
4
|
+
export { createEvent, createEventFactory, generateCorrelationId, generateEventId } from './core/factory.js';
|
|
5
|
+
export { DEFAULT_RETRY_CONFIGS, classifyError, createErrorClassifier, defaultErrorClassifier, isRetryableError } from './core/error-classification.js';
|
|
6
|
+
export { InMemoryIdempotencyStore, createInMemoryIdempotencyStore, generateCorrelationKey, generateIdempotencyKey } from './core/idempotency.js';
|
|
7
|
+
export { z } from 'zod';
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var tslib = require('tslib');
|
|
4
|
+
var common = require('@nestjs/common');
|
|
5
|
+
var bullmq = require('bullmq');
|
|
6
|
+
var dlq_service = require('./dlq.service.cjs');
|
|
7
|
+
var tokens = require('./tokens.cjs');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base class for event subscribers
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* @Injectable()
|
|
15
|
+
* export class TradeSubscriber extends BaseSubscriber {
|
|
16
|
+
* protected readonly config: SubscriberConfig = {
|
|
17
|
+
* name: 'trade-subscriber',
|
|
18
|
+
* eventTypes: ['TradeProposalCreated', 'TradeAccepted'],
|
|
19
|
+
* priority: 'high',
|
|
20
|
+
* concurrency: 5,
|
|
21
|
+
* };
|
|
22
|
+
*
|
|
23
|
+
* async handle(event: TradeProposalCreated | TradeAccepted): Promise<ProcessingResult> {
|
|
24
|
+
* switch (event.type) {
|
|
25
|
+
* case 'TradeProposalCreated':
|
|
26
|
+
* await this.handleTradeCreated(event);
|
|
27
|
+
* break;
|
|
28
|
+
* case 'TradeAccepted':
|
|
29
|
+
* await this.handleTradeAccepted(event);
|
|
30
|
+
* break;
|
|
31
|
+
* }
|
|
32
|
+
* return { success: true };
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
exports.BaseSubscriber = class BaseSubscriber {
|
|
38
|
+
busConfig;
|
|
39
|
+
idempotencyStore;
|
|
40
|
+
errorClassifier;
|
|
41
|
+
dlqService;
|
|
42
|
+
logger;
|
|
43
|
+
worker;
|
|
44
|
+
connection;
|
|
45
|
+
metrics = {
|
|
46
|
+
processed: 0,
|
|
47
|
+
succeeded: 0,
|
|
48
|
+
failed: 0,
|
|
49
|
+
retried: 0,
|
|
50
|
+
dlqSent: 0,
|
|
51
|
+
duplicatesSkipped: 0,
|
|
52
|
+
avgProcessingTimeMs: 0
|
|
53
|
+
};
|
|
54
|
+
constructor(busConfig, idempotencyStore, errorClassifier, dlqService) {
|
|
55
|
+
this.busConfig = busConfig;
|
|
56
|
+
this.idempotencyStore = idempotencyStore;
|
|
57
|
+
this.errorClassifier = errorClassifier;
|
|
58
|
+
this.dlqService = dlqService;
|
|
59
|
+
this.logger = new common.Logger(this.constructor.name);
|
|
60
|
+
this.connection = {
|
|
61
|
+
host: busConfig.redis.host,
|
|
62
|
+
port: busConfig.redis.port,
|
|
63
|
+
password: busConfig.redis.password,
|
|
64
|
+
db: busConfig.redis.db
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Called before processing starts (for setup/validation)
|
|
69
|
+
*/
|
|
70
|
+
async beforeProcess(
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
72
|
+
_event) {
|
|
73
|
+
// Override in subclass if needed
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Called after processing completes (for cleanup)
|
|
77
|
+
*/
|
|
78
|
+
async afterProcess(
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
80
|
+
_event,
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
82
|
+
_result) {
|
|
83
|
+
// Override in subclass if needed
|
|
84
|
+
}
|
|
85
|
+
async onModuleInit() {
|
|
86
|
+
const queueName = this.getQueueName();
|
|
87
|
+
this.logger.log(`Initializing subscriber "${this.config.name}" on queue "${queueName}" ` + `for events: ${this.config.eventTypes.join(', ')}`);
|
|
88
|
+
this.worker = new bullmq.Worker(queueName, async job => this.processJob(job), {
|
|
89
|
+
connection: this.connection,
|
|
90
|
+
concurrency: this.config.concurrency ?? 5,
|
|
91
|
+
lockDuration: this.config.lockDuration ?? 30000,
|
|
92
|
+
stalledInterval: this.config.stalledInterval ?? 30000
|
|
93
|
+
});
|
|
94
|
+
this.setupWorkerListeners();
|
|
95
|
+
this.logger.log(`Subscriber "${this.config.name}" started`);
|
|
96
|
+
}
|
|
97
|
+
async onModuleDestroy() {
|
|
98
|
+
this.logger.log(`Shutting down subscriber "${this.config.name}"...`);
|
|
99
|
+
if (this.worker) {
|
|
100
|
+
await this.worker.close();
|
|
101
|
+
}
|
|
102
|
+
this.logger.log(`Subscriber "${this.config.name}" stopped`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get queue name based on config
|
|
106
|
+
*/
|
|
107
|
+
getQueueName() {
|
|
108
|
+
if (this.config.queue) {
|
|
109
|
+
return this.config.queue;
|
|
110
|
+
}
|
|
111
|
+
// Find queue for priority
|
|
112
|
+
const priority = this.config.priority ?? 'normal';
|
|
113
|
+
const queueConfig = this.busConfig.queues?.find(q => q.priorities.includes(priority));
|
|
114
|
+
return queueConfig?.name ?? `events-${priority}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Process a job with idempotency and error handling
|
|
118
|
+
*/
|
|
119
|
+
async processJob(job) {
|
|
120
|
+
const event = job.data;
|
|
121
|
+
const startTime = Date.now();
|
|
122
|
+
// Filter by event type
|
|
123
|
+
if (!this.config.eventTypes.includes(event.type)) {
|
|
124
|
+
// Not our event type, skip silently
|
|
125
|
+
return {
|
|
126
|
+
skipped: true,
|
|
127
|
+
reason: 'event_type_not_handled'
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
this.logger.debug(`Processing event ${event.type}:${event.id} (attempt ${job.attemptsMade + 1})`);
|
|
131
|
+
try {
|
|
132
|
+
// Check idempotency
|
|
133
|
+
if (this.config.idempotency !== false) {
|
|
134
|
+
const idempotencyResult = await this.checkIdempotency(event);
|
|
135
|
+
if (idempotencyResult.isDuplicate) {
|
|
136
|
+
this.metrics.duplicatesSkipped++;
|
|
137
|
+
this.logger.debug(`Skipping duplicate event ${event.id}`);
|
|
138
|
+
return idempotencyResult.result ?? {
|
|
139
|
+
skipped: true,
|
|
140
|
+
reason: 'duplicate'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Before hook
|
|
145
|
+
await this.beforeProcess(event);
|
|
146
|
+
// Process event
|
|
147
|
+
const result = await this.handle(event);
|
|
148
|
+
// After hook
|
|
149
|
+
await this.afterProcess(event, result);
|
|
150
|
+
// Update idempotency store
|
|
151
|
+
if (this.config.idempotency !== false) {
|
|
152
|
+
await this.idempotencyStore.markCompleted(event, this.config.name, result.result);
|
|
153
|
+
}
|
|
154
|
+
// Update metrics
|
|
155
|
+
this.updateMetrics(startTime, true);
|
|
156
|
+
return result.result;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return this.handleError(job, event, error, startTime);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Handle processing error
|
|
163
|
+
*/
|
|
164
|
+
async handleError(job, event, error, startTime) {
|
|
165
|
+
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
166
|
+
const classification = this.errorClassifier.classify(error);
|
|
167
|
+
this.logger.error(`Error processing event ${event.type}:${event.id}: ${errorObj.message}`, errorObj.stack);
|
|
168
|
+
// Update idempotency store with failure
|
|
169
|
+
if (this.config.idempotency !== false) {
|
|
170
|
+
await this.idempotencyStore.markFailed(event, this.config.name, error);
|
|
171
|
+
}
|
|
172
|
+
// Check if we should retry
|
|
173
|
+
const maxAttempts = classification.retryConfig?.maxAttempts ?? 5;
|
|
174
|
+
const shouldRetry = job.attemptsMade < maxAttempts && classification.classification !== 'poison';
|
|
175
|
+
if (shouldRetry) {
|
|
176
|
+
this.metrics.retried++;
|
|
177
|
+
this.logger.debug(`Will retry event ${event.id}, attempt ${job.attemptsMade + 1}/${maxAttempts}`);
|
|
178
|
+
throw errorObj; // BullMQ will retry
|
|
179
|
+
}
|
|
180
|
+
// Send to DLQ
|
|
181
|
+
if (this.busConfig.enableDlq && !this.config.skipDlq) {
|
|
182
|
+
await this.sendToDlq(event, errorObj, classification, job.attemptsMade + 1);
|
|
183
|
+
}
|
|
184
|
+
// Update metrics
|
|
185
|
+
this.updateMetrics(startTime, false);
|
|
186
|
+
// Don't throw - we've handled it by sending to DLQ
|
|
187
|
+
return {
|
|
188
|
+
failed: true,
|
|
189
|
+
error: errorObj.message,
|
|
190
|
+
sentToDlq: true
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check idempotency
|
|
195
|
+
*/
|
|
196
|
+
async checkIdempotency(event) {
|
|
197
|
+
return this.idempotencyStore.check(event, this.config.name, {
|
|
198
|
+
acquireLock: true,
|
|
199
|
+
lockTtlMs: this.config.lockDuration ?? 30000
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Send failed event to DLQ
|
|
204
|
+
*/
|
|
205
|
+
async sendToDlq(event, error, classification, attempts) {
|
|
206
|
+
await this.dlqService.send({
|
|
207
|
+
event,
|
|
208
|
+
error: {
|
|
209
|
+
message: error.message,
|
|
210
|
+
stack: error.stack,
|
|
211
|
+
classification: classification.classification,
|
|
212
|
+
reason: classification.reason
|
|
213
|
+
},
|
|
214
|
+
subscriber: this.config.name,
|
|
215
|
+
attempts,
|
|
216
|
+
failedAt: new Date().toISOString()
|
|
217
|
+
});
|
|
218
|
+
this.metrics.dlqSent++;
|
|
219
|
+
this.logger.warn(`Event ${event.id} sent to DLQ after ${attempts} attempts. ` + `Classification: ${classification.classification}`);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Update metrics
|
|
223
|
+
*/
|
|
224
|
+
updateMetrics(startTime, success) {
|
|
225
|
+
const duration = Date.now() - startTime;
|
|
226
|
+
this.metrics.processed++;
|
|
227
|
+
if (success) {
|
|
228
|
+
this.metrics.succeeded++;
|
|
229
|
+
} else {
|
|
230
|
+
this.metrics.failed++;
|
|
231
|
+
}
|
|
232
|
+
// Rolling average
|
|
233
|
+
this.metrics.avgProcessingTimeMs = (this.metrics.avgProcessingTimeMs * (this.metrics.processed - 1) + duration) / this.metrics.processed;
|
|
234
|
+
this.metrics.lastProcessedAt = new Date();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Setup worker event listeners
|
|
238
|
+
*/
|
|
239
|
+
setupWorkerListeners() {
|
|
240
|
+
if (!this.worker) return;
|
|
241
|
+
this.worker.on('completed', job => {
|
|
242
|
+
this.logger.debug(`Job ${job.id} completed`);
|
|
243
|
+
});
|
|
244
|
+
this.worker.on('failed', (job, error) => {
|
|
245
|
+
this.logger.warn(`Job ${job?.id} failed: ${error.message}`);
|
|
246
|
+
});
|
|
247
|
+
this.worker.on('error', error => {
|
|
248
|
+
this.logger.error(`Worker error: ${error.message}`, error.stack);
|
|
249
|
+
});
|
|
250
|
+
this.worker.on('stalled', jobId => {
|
|
251
|
+
this.logger.warn(`Job ${jobId} stalled`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get current metrics
|
|
256
|
+
*/
|
|
257
|
+
getMetrics() {
|
|
258
|
+
return {
|
|
259
|
+
...this.metrics
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Pause processing
|
|
264
|
+
*/
|
|
265
|
+
async pause() {
|
|
266
|
+
if (this.worker) {
|
|
267
|
+
await this.worker.pause();
|
|
268
|
+
this.logger.log(`Subscriber "${this.config.name}" paused`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Resume processing
|
|
273
|
+
*/
|
|
274
|
+
async resume() {
|
|
275
|
+
if (this.worker) {
|
|
276
|
+
this.worker.resume();
|
|
277
|
+
this.logger.log(`Subscriber "${this.config.name}" resumed`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Check if worker is running
|
|
282
|
+
*/
|
|
283
|
+
isRunning() {
|
|
284
|
+
return this.worker?.isRunning() ?? false;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
exports.BaseSubscriber = tslib.__decorate([common.Injectable(), tslib.__param(0, common.Inject(tokens.EVENT_BUS_CONFIG)), tslib.__param(1, common.Inject(tokens.IDEMPOTENCY_STORE)), tslib.__param(2, common.Inject(tokens.ERROR_CLASSIFIER)), tslib.__metadata("design:paramtypes", [Object, Object, Object, dlq_service.DlqService])], exports.BaseSubscriber);
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { __decorate, __param, __metadata } from 'tslib';
|
|
2
|
+
import { Injectable, Inject, Logger } from '@nestjs/common';
|
|
3
|
+
import { Worker } from 'bullmq';
|
|
4
|
+
import { DlqService } from './dlq.service.js';
|
|
5
|
+
import { EVENT_BUS_CONFIG, IDEMPOTENCY_STORE, ERROR_CLASSIFIER } from './tokens.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base class for event subscribers
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* @Injectable()
|
|
13
|
+
* export class TradeSubscriber extends BaseSubscriber {
|
|
14
|
+
* protected readonly config: SubscriberConfig = {
|
|
15
|
+
* name: 'trade-subscriber',
|
|
16
|
+
* eventTypes: ['TradeProposalCreated', 'TradeAccepted'],
|
|
17
|
+
* priority: 'high',
|
|
18
|
+
* concurrency: 5,
|
|
19
|
+
* };
|
|
20
|
+
*
|
|
21
|
+
* async handle(event: TradeProposalCreated | TradeAccepted): Promise<ProcessingResult> {
|
|
22
|
+
* switch (event.type) {
|
|
23
|
+
* case 'TradeProposalCreated':
|
|
24
|
+
* await this.handleTradeCreated(event);
|
|
25
|
+
* break;
|
|
26
|
+
* case 'TradeAccepted':
|
|
27
|
+
* await this.handleTradeAccepted(event);
|
|
28
|
+
* break;
|
|
29
|
+
* }
|
|
30
|
+
* return { success: true };
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
let BaseSubscriber = class BaseSubscriber {
|
|
36
|
+
busConfig;
|
|
37
|
+
idempotencyStore;
|
|
38
|
+
errorClassifier;
|
|
39
|
+
dlqService;
|
|
40
|
+
logger;
|
|
41
|
+
worker;
|
|
42
|
+
connection;
|
|
43
|
+
metrics = {
|
|
44
|
+
processed: 0,
|
|
45
|
+
succeeded: 0,
|
|
46
|
+
failed: 0,
|
|
47
|
+
retried: 0,
|
|
48
|
+
dlqSent: 0,
|
|
49
|
+
duplicatesSkipped: 0,
|
|
50
|
+
avgProcessingTimeMs: 0
|
|
51
|
+
};
|
|
52
|
+
constructor(busConfig, idempotencyStore, errorClassifier, dlqService) {
|
|
53
|
+
this.busConfig = busConfig;
|
|
54
|
+
this.idempotencyStore = idempotencyStore;
|
|
55
|
+
this.errorClassifier = errorClassifier;
|
|
56
|
+
this.dlqService = dlqService;
|
|
57
|
+
this.logger = new Logger(this.constructor.name);
|
|
58
|
+
this.connection = {
|
|
59
|
+
host: busConfig.redis.host,
|
|
60
|
+
port: busConfig.redis.port,
|
|
61
|
+
password: busConfig.redis.password,
|
|
62
|
+
db: busConfig.redis.db
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Called before processing starts (for setup/validation)
|
|
67
|
+
*/
|
|
68
|
+
async beforeProcess(
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
70
|
+
_event) {
|
|
71
|
+
// Override in subclass if needed
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Called after processing completes (for cleanup)
|
|
75
|
+
*/
|
|
76
|
+
async afterProcess(
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
78
|
+
_event,
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
80
|
+
_result) {
|
|
81
|
+
// Override in subclass if needed
|
|
82
|
+
}
|
|
83
|
+
async onModuleInit() {
|
|
84
|
+
const queueName = this.getQueueName();
|
|
85
|
+
this.logger.log(`Initializing subscriber "${this.config.name}" on queue "${queueName}" ` + `for events: ${this.config.eventTypes.join(', ')}`);
|
|
86
|
+
this.worker = new Worker(queueName, async job => this.processJob(job), {
|
|
87
|
+
connection: this.connection,
|
|
88
|
+
concurrency: this.config.concurrency ?? 5,
|
|
89
|
+
lockDuration: this.config.lockDuration ?? 30000,
|
|
90
|
+
stalledInterval: this.config.stalledInterval ?? 30000
|
|
91
|
+
});
|
|
92
|
+
this.setupWorkerListeners();
|
|
93
|
+
this.logger.log(`Subscriber "${this.config.name}" started`);
|
|
94
|
+
}
|
|
95
|
+
async onModuleDestroy() {
|
|
96
|
+
this.logger.log(`Shutting down subscriber "${this.config.name}"...`);
|
|
97
|
+
if (this.worker) {
|
|
98
|
+
await this.worker.close();
|
|
99
|
+
}
|
|
100
|
+
this.logger.log(`Subscriber "${this.config.name}" stopped`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get queue name based on config
|
|
104
|
+
*/
|
|
105
|
+
getQueueName() {
|
|
106
|
+
if (this.config.queue) {
|
|
107
|
+
return this.config.queue;
|
|
108
|
+
}
|
|
109
|
+
// Find queue for priority
|
|
110
|
+
const priority = this.config.priority ?? 'normal';
|
|
111
|
+
const queueConfig = this.busConfig.queues?.find(q => q.priorities.includes(priority));
|
|
112
|
+
return queueConfig?.name ?? `events-${priority}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Process a job with idempotency and error handling
|
|
116
|
+
*/
|
|
117
|
+
async processJob(job) {
|
|
118
|
+
const event = job.data;
|
|
119
|
+
const startTime = Date.now();
|
|
120
|
+
// Filter by event type
|
|
121
|
+
if (!this.config.eventTypes.includes(event.type)) {
|
|
122
|
+
// Not our event type, skip silently
|
|
123
|
+
return {
|
|
124
|
+
skipped: true,
|
|
125
|
+
reason: 'event_type_not_handled'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
this.logger.debug(`Processing event ${event.type}:${event.id} (attempt ${job.attemptsMade + 1})`);
|
|
129
|
+
try {
|
|
130
|
+
// Check idempotency
|
|
131
|
+
if (this.config.idempotency !== false) {
|
|
132
|
+
const idempotencyResult = await this.checkIdempotency(event);
|
|
133
|
+
if (idempotencyResult.isDuplicate) {
|
|
134
|
+
this.metrics.duplicatesSkipped++;
|
|
135
|
+
this.logger.debug(`Skipping duplicate event ${event.id}`);
|
|
136
|
+
return idempotencyResult.result ?? {
|
|
137
|
+
skipped: true,
|
|
138
|
+
reason: 'duplicate'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Before hook
|
|
143
|
+
await this.beforeProcess(event);
|
|
144
|
+
// Process event
|
|
145
|
+
const result = await this.handle(event);
|
|
146
|
+
// After hook
|
|
147
|
+
await this.afterProcess(event, result);
|
|
148
|
+
// Update idempotency store
|
|
149
|
+
if (this.config.idempotency !== false) {
|
|
150
|
+
await this.idempotencyStore.markCompleted(event, this.config.name, result.result);
|
|
151
|
+
}
|
|
152
|
+
// Update metrics
|
|
153
|
+
this.updateMetrics(startTime, true);
|
|
154
|
+
return result.result;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return this.handleError(job, event, error, startTime);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Handle processing error
|
|
161
|
+
*/
|
|
162
|
+
async handleError(job, event, error, startTime) {
|
|
163
|
+
const errorObj = error instanceof Error ? error : new Error(String(error));
|
|
164
|
+
const classification = this.errorClassifier.classify(error);
|
|
165
|
+
this.logger.error(`Error processing event ${event.type}:${event.id}: ${errorObj.message}`, errorObj.stack);
|
|
166
|
+
// Update idempotency store with failure
|
|
167
|
+
if (this.config.idempotency !== false) {
|
|
168
|
+
await this.idempotencyStore.markFailed(event, this.config.name, error);
|
|
169
|
+
}
|
|
170
|
+
// Check if we should retry
|
|
171
|
+
const maxAttempts = classification.retryConfig?.maxAttempts ?? 5;
|
|
172
|
+
const shouldRetry = job.attemptsMade < maxAttempts && classification.classification !== 'poison';
|
|
173
|
+
if (shouldRetry) {
|
|
174
|
+
this.metrics.retried++;
|
|
175
|
+
this.logger.debug(`Will retry event ${event.id}, attempt ${job.attemptsMade + 1}/${maxAttempts}`);
|
|
176
|
+
throw errorObj; // BullMQ will retry
|
|
177
|
+
}
|
|
178
|
+
// Send to DLQ
|
|
179
|
+
if (this.busConfig.enableDlq && !this.config.skipDlq) {
|
|
180
|
+
await this.sendToDlq(event, errorObj, classification, job.attemptsMade + 1);
|
|
181
|
+
}
|
|
182
|
+
// Update metrics
|
|
183
|
+
this.updateMetrics(startTime, false);
|
|
184
|
+
// Don't throw - we've handled it by sending to DLQ
|
|
185
|
+
return {
|
|
186
|
+
failed: true,
|
|
187
|
+
error: errorObj.message,
|
|
188
|
+
sentToDlq: true
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Check idempotency
|
|
193
|
+
*/
|
|
194
|
+
async checkIdempotency(event) {
|
|
195
|
+
return this.idempotencyStore.check(event, this.config.name, {
|
|
196
|
+
acquireLock: true,
|
|
197
|
+
lockTtlMs: this.config.lockDuration ?? 30000
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Send failed event to DLQ
|
|
202
|
+
*/
|
|
203
|
+
async sendToDlq(event, error, classification, attempts) {
|
|
204
|
+
await this.dlqService.send({
|
|
205
|
+
event,
|
|
206
|
+
error: {
|
|
207
|
+
message: error.message,
|
|
208
|
+
stack: error.stack,
|
|
209
|
+
classification: classification.classification,
|
|
210
|
+
reason: classification.reason
|
|
211
|
+
},
|
|
212
|
+
subscriber: this.config.name,
|
|
213
|
+
attempts,
|
|
214
|
+
failedAt: new Date().toISOString()
|
|
215
|
+
});
|
|
216
|
+
this.metrics.dlqSent++;
|
|
217
|
+
this.logger.warn(`Event ${event.id} sent to DLQ after ${attempts} attempts. ` + `Classification: ${classification.classification}`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Update metrics
|
|
221
|
+
*/
|
|
222
|
+
updateMetrics(startTime, success) {
|
|
223
|
+
const duration = Date.now() - startTime;
|
|
224
|
+
this.metrics.processed++;
|
|
225
|
+
if (success) {
|
|
226
|
+
this.metrics.succeeded++;
|
|
227
|
+
} else {
|
|
228
|
+
this.metrics.failed++;
|
|
229
|
+
}
|
|
230
|
+
// Rolling average
|
|
231
|
+
this.metrics.avgProcessingTimeMs = (this.metrics.avgProcessingTimeMs * (this.metrics.processed - 1) + duration) / this.metrics.processed;
|
|
232
|
+
this.metrics.lastProcessedAt = new Date();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Setup worker event listeners
|
|
236
|
+
*/
|
|
237
|
+
setupWorkerListeners() {
|
|
238
|
+
if (!this.worker) return;
|
|
239
|
+
this.worker.on('completed', job => {
|
|
240
|
+
this.logger.debug(`Job ${job.id} completed`);
|
|
241
|
+
});
|
|
242
|
+
this.worker.on('failed', (job, error) => {
|
|
243
|
+
this.logger.warn(`Job ${job?.id} failed: ${error.message}`);
|
|
244
|
+
});
|
|
245
|
+
this.worker.on('error', error => {
|
|
246
|
+
this.logger.error(`Worker error: ${error.message}`, error.stack);
|
|
247
|
+
});
|
|
248
|
+
this.worker.on('stalled', jobId => {
|
|
249
|
+
this.logger.warn(`Job ${jobId} stalled`);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Get current metrics
|
|
254
|
+
*/
|
|
255
|
+
getMetrics() {
|
|
256
|
+
return {
|
|
257
|
+
...this.metrics
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Pause processing
|
|
262
|
+
*/
|
|
263
|
+
async pause() {
|
|
264
|
+
if (this.worker) {
|
|
265
|
+
await this.worker.pause();
|
|
266
|
+
this.logger.log(`Subscriber "${this.config.name}" paused`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Resume processing
|
|
271
|
+
*/
|
|
272
|
+
async resume() {
|
|
273
|
+
if (this.worker) {
|
|
274
|
+
this.worker.resume();
|
|
275
|
+
this.logger.log(`Subscriber "${this.config.name}" resumed`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Check if worker is running
|
|
280
|
+
*/
|
|
281
|
+
isRunning() {
|
|
282
|
+
return this.worker?.isRunning() ?? false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
BaseSubscriber = __decorate([Injectable(), __param(0, Inject(EVENT_BUS_CONFIG)), __param(1, Inject(IDEMPOTENCY_STORE)), __param(2, Inject(ERROR_CLASSIFIER)), __metadata("design:paramtypes", [Object, Object, Object, DlqService])], BaseSubscriber);
|
|
286
|
+
|
|
287
|
+
export { BaseSubscriber };
|