Skip to content

Commit 92c42cd

Browse files
authored
Merge pull request #749 from RaymondAbiola/Event_Emitter
[Backend] — Event Emitter Has No Dead Letter Queue
2 parents 31e0b21 + 532b6fc commit 92c42cd

14 files changed

Lines changed: 1125 additions & 41 deletions

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@keyv/redis": "^5.1.6",
2828
"@nestjs-modules/mailer": "^2.0.2",
2929
"@nestjs/axios": "^4.0.1",
30+
"@nestjs/bull": "^11.0.2",
3031
"@nestjs/cache-manager": "^3.1.0",
3132
"@nestjs/common": "^11.0.1",
3233
"@nestjs/config": "^4.0.3",
@@ -44,6 +45,7 @@
4445
"archiver": "^7.0.1",
4546
"axios": "^1.13.5",
4647
"bcrypt": "^6.0.0",
48+
"bull": "^4.16.5",
4749
"cache-manager": "^7.2.8",
4850
"cache-manager-redis-store": "^3.0.1",
4951
"cacheable": "^2.3.4",
@@ -81,6 +83,7 @@
8183
"@swc/core": "^1.10.7",
8284
"@types/archiver": "^6.0.3",
8385
"@types/bcrypt": "^6.0.0",
86+
"@types/bull": "^3.15.9",
8487
"@types/express": "^5.0.0",
8588
"@types/jest": "^29.5.14",
8689
"@types/multer": "^2.0.0",

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RequestLoggingInterceptor } from './common/interceptors/request-logging
99
import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor';
1010
import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard';
1111
import { CommonModule } from './common/common.module';
12+
import { EventBusModule } from './common/event-bus/event-bus.module';
1213
import { EventEmitterModule } from '@nestjs/event-emitter';
1314
import { LoggerModule } from 'nestjs-pino';
1415
import * as Joi from 'joi';
@@ -219,6 +220,7 @@ const envValidationSchema = Joi.object({
219220
PostmanModule,
220221
PerformanceModule,
221222
CommonModule,
223+
EventBusModule.forRoot(),
222224
ThrottlerModule.forRoot([
223225
{
224226
name: 'default',
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { ArrayMinSize, ArrayMaxSize, IsArray, IsUUID } from 'class-validator';
3+
4+
export class ReplayEventsDto {
5+
@ApiProperty({
6+
description: 'IDs of failed events to replay',
7+
type: [String],
8+
minItems: 1,
9+
maxItems: 100,
10+
})
11+
@IsArray()
12+
@ArrayMinSize(1)
13+
@ArrayMaxSize(100)
14+
@IsUUID('4', { each: true })
15+
ids: string[];
16+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
Index,
8+
} from 'typeorm';
9+
10+
export enum FailedEventStatus {
11+
PENDING_RETRY = 'pending_retry',
12+
DEAD = 'dead',
13+
REPLAYED = 'replayed',
14+
DISCARDED = 'discarded',
15+
}
16+
17+
/**
18+
* General-purpose Dead Letter Queue record for events whose
19+
* EventEmitter2 listeners failed after all retry attempts were exhausted.
20+
*
21+
* Distinct from `DeadLetterEvent`, which is scoped to raw Soroban indexer events.
22+
*/
23+
@Entity('failed_events')
24+
@Index('idx_failed_events_status_created', ['status', 'createdAt'])
25+
@Index('idx_failed_events_event_name', ['eventName'])
26+
export class FailedEvent {
27+
@PrimaryGeneratedColumn('uuid')
28+
id: string;
29+
30+
@Column({ type: 'varchar', length: 255 })
31+
eventName: string;
32+
33+
/** Original event payload, serialised. */
34+
@Column({ type: 'jsonb' })
35+
payload: unknown;
36+
37+
@Column({ type: 'text', nullable: true })
38+
errorMessage: string | null;
39+
40+
@Column({ type: 'text', nullable: true })
41+
errorStack: string | null;
42+
43+
@Column({ type: 'int', default: 0 })
44+
attempts: number;
45+
46+
@Column({ type: 'int', default: 3 })
47+
maxAttempts: number;
48+
49+
@Column({
50+
type: 'enum',
51+
enum: FailedEventStatus,
52+
default: FailedEventStatus.PENDING_RETRY,
53+
})
54+
status: FailedEventStatus;
55+
56+
/** Optional source identifier (service, module) that emitted the event. */
57+
@Column({ type: 'varchar', length: 255, nullable: true })
58+
source: string | null;
59+
60+
/** Optional correlation id for distributed tracing. */
61+
@Column({ type: 'varchar', length: 255, nullable: true })
62+
correlationId: string | null;
63+
64+
@Column({ type: 'timestamp with time zone', nullable: true })
65+
lastAttemptAt: Date | null;
66+
67+
@Column({ type: 'timestamp with time zone', nullable: true })
68+
nextRetryAt: Date | null;
69+
70+
@CreateDateColumn({ type: 'timestamp with time zone' })
71+
createdAt: Date;
72+
73+
@UpdateDateColumn({ type: 'timestamp with time zone' })
74+
updatedAt: Date;
75+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const EVENT_DLQ_QUEUE = 'event-dlq';
2+
3+
export const EVENT_DLQ_RETRY_JOB = 'retry-failed-event';
4+
5+
/**
6+
* Default retry policy used by ResilientEventBus when a caller does
7+
* not provide explicit options.
8+
*/
9+
export const DEFAULT_MAX_ATTEMPTS = 3;
10+
export const DEFAULT_BASE_BACKOFF_MS = 1000;
11+
export const MAX_BACKOFF_MS = 30 * 1000;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { DynamicModule, Global, Logger, Module } from '@nestjs/common';
2+
import { ConfigModule, ConfigService } from '@nestjs/config';
3+
import { TypeOrmModule } from '@nestjs/typeorm';
4+
import { BullModule } from '@nestjs/bull';
5+
import { FailedEvent } from './entities/failed-event.entity';
6+
import { EventDlqService } from './event-dlq.service';
7+
import { ResilientEventBus } from './resilient-event-bus.service';
8+
import { EventDlqController } from './event-dlq.controller';
9+
import { EventDlqProcessor } from './event-dlq.processor';
10+
import { EVENT_DLQ_QUEUE } from './event-bus.constants';
11+
12+
/**
13+
* Wires the Resilient Event Bus + DLQ infrastructure.
14+
*
15+
* Bull-backed async retries are activated only when REDIS_URL is configured.
16+
* Without Redis, failed events are still recorded to the DB and can be
17+
* replayed manually via the admin endpoints — they are simply marked DEAD
18+
* immediately rather than retried in the background.
19+
*/
20+
@Global()
21+
@Module({})
22+
export class EventBusModule {
23+
private static readonly logger = new Logger(EventBusModule.name);
24+
25+
static forRoot(): DynamicModule {
26+
const redisUrl = process.env.REDIS_URL;
27+
28+
const bullImports = redisUrl
29+
? [
30+
BullModule.forRootAsync({
31+
imports: [ConfigModule],
32+
inject: [ConfigService],
33+
useFactory: (config: ConfigService) => {
34+
const url = config.get<string>('REDIS_URL');
35+
return {
36+
redis: url,
37+
defaultJobOptions: {
38+
attempts: 3,
39+
backoff: { type: 'exponential', delay: 1000 },
40+
removeOnComplete: true,
41+
removeOnFail: false,
42+
},
43+
};
44+
},
45+
}),
46+
BullModule.registerQueue({ name: EVENT_DLQ_QUEUE }),
47+
]
48+
: [];
49+
50+
if (!redisUrl) {
51+
this.logger.warn(
52+
'REDIS_URL not configured — Event DLQ will run without async Bull retries. Failed events will be persisted and available for manual replay only.',
53+
);
54+
}
55+
56+
const providers = redisUrl
57+
? [ResilientEventBus, EventDlqService, EventDlqProcessor]
58+
: [ResilientEventBus, EventDlqService];
59+
60+
return {
61+
module: EventBusModule,
62+
imports: [TypeOrmModule.forFeature([FailedEvent]), ...bullImports],
63+
controllers: [EventDlqController],
64+
providers,
65+
exports: [ResilientEventBus, EventDlqService],
66+
};
67+
}
68+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
NotFoundException,
7+
Param,
8+
ParseUUIDPipe,
9+
Post,
10+
Query,
11+
UseGuards,
12+
} from '@nestjs/common';
13+
import {
14+
ApiBearerAuth,
15+
ApiOperation,
16+
ApiQuery,
17+
ApiResponse,
18+
ApiTags,
19+
} from '@nestjs/swagger';
20+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
21+
import { RolesGuard } from '../guards/roles.guard';
22+
import { Roles } from '../decorators/roles.decorator';
23+
import { Role } from '../enums/role.enum';
24+
import { EventDlqService } from './event-dlq.service';
25+
import { FailedEventStatus } from './entities/failed-event.entity';
26+
import { ReplayEventsDto } from './dto/replay-events.dto';
27+
28+
@ApiTags('admin: event-dlq')
29+
@ApiBearerAuth()
30+
@Controller('admin/event-dlq')
31+
@UseGuards(JwtAuthGuard, RolesGuard)
32+
@Roles(Role.ADMIN)
33+
export class EventDlqController {
34+
constructor(private readonly dlqService: EventDlqService) {}
35+
36+
@Get('stats')
37+
@ApiOperation({ summary: 'DLQ summary stats for monitoring dashboards' })
38+
@ApiResponse({ status: 200, description: 'Counts by status & event name' })
39+
async getStats() {
40+
return this.dlqService.getStats();
41+
}
42+
43+
@Get()
44+
@ApiOperation({ summary: 'List failed events' })
45+
@ApiQuery({ name: 'status', enum: FailedEventStatus, required: false })
46+
@ApiQuery({ name: 'eventName', required: false })
47+
@ApiQuery({ name: 'limit', required: false, type: Number })
48+
@ApiQuery({ name: 'offset', required: false, type: Number })
49+
async list(
50+
@Query('status') status?: FailedEventStatus,
51+
@Query('eventName') eventName?: string,
52+
@Query('limit') limit?: string,
53+
@Query('offset') offset?: string,
54+
) {
55+
return this.dlqService.list({
56+
status,
57+
eventName,
58+
limit: limit ? parseInt(limit, 10) : undefined,
59+
offset: offset ? parseInt(offset, 10) : undefined,
60+
});
61+
}
62+
63+
@Get(':id')
64+
@ApiOperation({ summary: 'Get a failed event by id' })
65+
async getOne(@Param('id', new ParseUUIDPipe()) id: string) {
66+
const record = await this.dlqService.getOne(id);
67+
if (!record) throw new NotFoundException('Failed event not found');
68+
return record;
69+
}
70+
71+
@Post(':id/replay')
72+
@ApiOperation({ summary: 'Replay a single failed event' })
73+
async replayOne(@Param('id', new ParseUUIDPipe()) id: string) {
74+
const result = await this.dlqService.replay(id);
75+
if (!result.success && result.error === 'Failed event not found') {
76+
throw new NotFoundException(result.error);
77+
}
78+
return result;
79+
}
80+
81+
@Post('replay')
82+
@ApiOperation({ summary: 'Replay multiple failed events' })
83+
async replayMany(@Body() dto: ReplayEventsDto) {
84+
return this.dlqService.replayMany(dto.ids);
85+
}
86+
87+
@Delete(':id')
88+
@ApiOperation({ summary: 'Discard a failed event without replaying' })
89+
async discard(@Param('id', new ParseUUIDPipe()) id: string) {
90+
const ok = await this.dlqService.discard(id);
91+
if (!ok) throw new NotFoundException('Failed event not found');
92+
return { discarded: true };
93+
}
94+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Process, Processor } from '@nestjs/bull';
2+
import { Logger } from '@nestjs/common';
3+
import { Job } from 'bull';
4+
import { EventDlqService } from './event-dlq.service';
5+
import { EVENT_DLQ_QUEUE, EVENT_DLQ_RETRY_JOB } from './event-bus.constants';
6+
7+
interface RetryJobData {
8+
failedEventId: string;
9+
}
10+
11+
@Processor(EVENT_DLQ_QUEUE)
12+
export class EventDlqProcessor {
13+
private readonly logger = new Logger(EventDlqProcessor.name);
14+
15+
constructor(private readonly dlqService: EventDlqService) {}
16+
17+
@Process(EVENT_DLQ_RETRY_JOB)
18+
async handleRetry(job: Job<RetryJobData>): Promise<void> {
19+
const { failedEventId } = job.data;
20+
this.logger.debug(
21+
`Processing retry job ${job.id} for failed event ${failedEventId} (Bull attempt ${job.attemptsMade + 1})`,
22+
);
23+
await this.dlqService.runRetryJob(failedEventId);
24+
}
25+
}

0 commit comments

Comments
 (0)