Skip to content

Commit f9690a8

Browse files
committed
chore: merge feat/delivery-module into main
2 parents 5524a78 + b5beaf9 commit f9690a8

5 files changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common'
2+
import { TasksRepository } from '../tasks/tasks.repository.js'
3+
import { WebhookProcessor } from './workers/webhook.processor.js'
4+
5+
/**
6+
* Worker-side module for the Delivery domain.
7+
* Contains only the webhook BullMQ processor.
8+
* Intentionally excludes HTTP controllers and SseService to keep the
9+
* worker process lean and free of any HTTP-serving infrastructure.
10+
*/
11+
@Module({
12+
providers: [WebhookProcessor, TasksRepository],
13+
})
14+
export class DeliveryWorkerModule {}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Module } from '@nestjs/common'
2+
import { TasksRepository } from '../tasks/tasks.repository.js'
3+
import { EventsRepository } from './events.repository.js'
4+
import { SseService } from './sse.service.js'
5+
import { StreamController } from './stream.controller.js'
6+
7+
/**
8+
* API-side module for the Delivery domain.
9+
* Registers the SSE stream endpoint and its dependencies.
10+
* Intentionally excludes WebhookProcessor — that belongs to the Worker process.
11+
*/
12+
@Module({
13+
controllers: [StreamController],
14+
providers: [SseService, TasksRepository, EventsRepository],
15+
exports: [EventsRepository],
16+
})
17+
export class DeliveryModule {}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
2+
import { Observable } from 'rxjs'
3+
import type { MessageEvent } from '@nestjs/common'
4+
import type { Redis } from 'ioredis'
5+
import { RedisService } from '#libs/redis/index.js'
6+
import { TasksRepository } from '../tasks/tasks.repository.js'
7+
import { EventsRepository } from './events.repository.js'
8+
import type { TaskEventSelect, TaskEventType } from './schemas/events.sql.js'
9+
10+
const TERMINAL_EVENT_TYPES = new Set<TaskEventType>(['completed', 'failed', 'cancelled'])
11+
12+
/**
13+
* 25 s keeps the connection alive through reverse proxies (nginx proxy_read_timeout,
14+
* ALB idle timeout) that would otherwise silently drop quiet SSE connections.
15+
*/
16+
const HEARTBEAT_INTERVAL_MS = 25_000
17+
18+
const HEARTBEAT_EVENT: MessageEvent = { data: '', type: 'ping' }
19+
20+
/**
21+
* Maps a DB event row to the SSE wire format.
22+
* `seq` becomes Last-Event-ID so clients can resume after disconnect.
23+
* DB-internal fields (id, taskId, createdAt) are stripped from the payload.
24+
*/
25+
function toMessageEvent(event: TaskEventSelect): MessageEvent {
26+
const { id: _id, taskId: _taskId, seq, createdAt: _createdAt, ...eventPayload } = event
27+
return {
28+
id: String(seq),
29+
type: eventPayload.eventType,
30+
data: JSON.stringify(eventPayload),
31+
}
32+
}
33+
34+
/**
35+
* Delivers task lifecycle events over SSE via event sourcing + Redis Pub/Sub.
36+
*
37+
* Each connection gets a dedicated ioredis subscriber client because ioredis
38+
* enters subscriber-only mode on `.subscribe()`, blocking all other commands
39+
* on that connection.
40+
*/
41+
@Injectable()
42+
export class SseService {
43+
private readonly logger = new Logger(SseService.name)
44+
45+
constructor(
46+
private readonly redis: RedisService,
47+
private readonly tasksRepo: TasksRepository,
48+
private readonly eventsRepo: EventsRepository,
49+
) {}
50+
51+
/**
52+
* Opens an SSE stream for a task. Replays history from `afterSeq`,
53+
* then streams live Pub/Sub events until a terminal state is reached.
54+
* Includes parallel heartbeat pings to prevent proxy idle timeouts.
55+
*/
56+
async stream(taskId: string, afterSeq: number): Promise<Observable<MessageEvent>> {
57+
const task = await this.tasksRepo.findById(taskId)
58+
if (!task) throw new NotFoundException(`Task ${taskId} not found`)
59+
60+
return new Observable<MessageEvent>((subscriber) => {
61+
let isDone = false
62+
let historyLoaded = false
63+
let highestHistorySeq = afterSeq
64+
const liveBuffer: TaskEventSelect[] = []
65+
66+
// Dedicated subscriber client — isolated from the shared business connection.
67+
const subClient = (this.redis.client as Redis).duplicate()
68+
const channel = `task:${taskId}`
69+
70+
void subClient.subscribe(channel, (err) => {
71+
if (err) {
72+
this.logger.error(`Failed to subscribe to channel ${channel}: ${err.message}`)
73+
subscriber.error(err)
74+
}
75+
})
76+
77+
subClient.on('message', (_chan: string, rawMessage: string) => {
78+
if (isDone) return
79+
try {
80+
const event = JSON.parse(rawMessage) as TaskEventSelect
81+
82+
if (!historyLoaded) {
83+
liveBuffer.push(event)
84+
return
85+
}
86+
87+
if (event.seq > highestHistorySeq) {
88+
highestHistorySeq = event.seq
89+
subscriber.next(toMessageEvent(event))
90+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
91+
isDone = true
92+
subscriber.complete()
93+
}
94+
}
95+
} catch (err) {
96+
this.logger.error(
97+
`Failed to parse Pub/Sub message on ${channel}: ${String(err)}`,
98+
)
99+
}
100+
})
101+
102+
subClient.on('error', (err: Error) => {
103+
this.logger.error(`Redis subscriber error on ${channel}: ${err.message}`)
104+
subscriber.error(err)
105+
})
106+
107+
const heartbeatTimer = setInterval(() => {
108+
if (!isDone) subscriber.next(HEARTBEAT_EVENT)
109+
}, HEARTBEAT_INTERVAL_MS)
110+
111+
// Fetch history AFTER subscribing to Redis to guarantee no missed events.
112+
this.eventsRepo
113+
.findEventsSince(taskId, afterSeq)
114+
.then((events) => {
115+
if (isDone) return
116+
117+
for (const event of events) {
118+
highestHistorySeq = Math.max(highestHistorySeq, event.seq)
119+
subscriber.next(toMessageEvent(event))
120+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
121+
isDone = true
122+
subscriber.complete()
123+
return
124+
}
125+
}
126+
127+
historyLoaded = true
128+
for (const event of liveBuffer) {
129+
if (event.seq > highestHistorySeq) {
130+
highestHistorySeq = event.seq
131+
subscriber.next(toMessageEvent(event))
132+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
133+
isDone = true
134+
subscriber.complete()
135+
return
136+
}
137+
}
138+
}
139+
})
140+
.catch((err: unknown) => {
141+
if (!isDone) subscriber.error(err)
142+
})
143+
144+
return () => {
145+
isDone = true
146+
clearInterval(heartbeatTimer)
147+
subClient
148+
.unsubscribe(channel)
149+
.finally(() => {
150+
void subClient.quit()
151+
})
152+
.catch((err: unknown) => {
153+
this.logger.error(`Failed to unsubscribe SSE client: ${String(err)}`)
154+
})
155+
this.logger.debug(`SSE stream closed for taskId=${taskId}`)
156+
}
157+
})
158+
}
159+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
Controller,
3+
Get,
4+
Header,
5+
Headers,
6+
HttpStatus,
7+
Param,
8+
ParseUUIDPipe,
9+
Sse,
10+
} from '@nestjs/common'
11+
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'
12+
import type { MessageEvent } from '@nestjs/common'
13+
import type { Observable } from 'rxjs'
14+
import { SseService } from './sse.service.js'
15+
16+
/**
17+
* Parses the `Last-Event-ID` header into a non-negative integer sequence number.
18+
* Falls back to 0 (replay all) on missing or malformed values — this is
19+
* intentionally forgiving: a wrong seq causes extra data, not data loss.
20+
*/
21+
function parseLastEventId(raw: string | undefined): number {
22+
if (!raw) return 0
23+
const parsed = Number(raw)
24+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0
25+
}
26+
27+
/**
28+
* Exposes the Server-Sent Events endpoint for real-time task lifecycle notifications.
29+
* Uses event sourcing: clients reconnecting with `Last-Event-ID` receive all missed events.
30+
*/
31+
@ApiTags('tasks')
32+
@Controller('tasks')
33+
export class StreamController {
34+
constructor(private readonly sseService: SseService) {}
35+
36+
/**
37+
* Opens a persistent SSE connection. Replays missed events from `Last-Event-ID`,
38+
* then delivers live updates until the task reaches a terminal state.
39+
*/
40+
@Get(':id/stream')
41+
@Sse()
42+
// Prevent reverse proxies and CDNs from buffering the SSE response.
43+
@Header('X-Accel-Buffering', 'no')
44+
@Header('Cache-Control', 'no-cache')
45+
@ApiOperation({ summary: 'Open SSE stream for real-time task events' })
46+
@ApiHeader({
47+
name: 'Last-Event-ID',
48+
description: 'Resume replay from this event sequence number (inclusive)',
49+
required: false,
50+
})
51+
@ApiResponse({
52+
status: HttpStatus.OK,
53+
description: 'SSE stream opened; events follow task lifecycle',
54+
})
55+
@ApiResponse({
56+
status: HttpStatus.NOT_FOUND,
57+
description: 'Task not found',
58+
})
59+
async stream(
60+
@Param('id', ParseUUIDPipe) id: string,
61+
@Headers('last-event-id') lastEventId: string | undefined,
62+
): Promise<Observable<MessageEvent>> {
63+
const afterSeq = parseLastEventId(lastEventId)
64+
return this.sseService.stream(id, afterSeq)
65+
}
66+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
Injectable,
3+
Logger,
4+
type OnModuleDestroy,
5+
type OnModuleInit,
6+
} from '@nestjs/common'
7+
import { Worker } from 'bullmq'
8+
import { RedisService } from '#libs/redis/index.js'
9+
import {
10+
HttpRetryService,
11+
DispatchFailedError,
12+
NonRetryableError,
13+
} from '#libs/http-retry/index.js'
14+
import { TasksRepository } from '../../tasks/tasks.repository.js'
15+
16+
interface WebhookJobData {
17+
taskId: string
18+
}
19+
20+
/**
21+
* Consumes the `webhook` queue.
22+
* Delivers the final task result to the client-configured webhookUrl (best-effort).
23+
*
24+
* The task is already in a terminal state by the time this runs — delivery
25+
* failure does NOT change task status. Uses exponential backoff for transient
26+
* failures; 4xx responses are treated as non-retryable client misconfiguration.
27+
*/
28+
@Injectable()
29+
export class WebhookProcessor implements OnModuleInit, OnModuleDestroy {
30+
private readonly logger = new Logger(WebhookProcessor.name)
31+
private worker!: Worker
32+
33+
constructor(
34+
private readonly redis: RedisService,
35+
private readonly tasksRepo: TasksRepository,
36+
private readonly httpRetry: HttpRetryService,
37+
) {}
38+
39+
onModuleInit(): void {
40+
this.worker = new Worker(
41+
'webhook',
42+
(job) => this.process(job.data as WebhookJobData),
43+
{
44+
// Workers must not share the business Redis connection.
45+
connection: (this.redis.client as import('ioredis').Redis).duplicate(),
46+
concurrency: 20,
47+
},
48+
)
49+
50+
this.worker.on('failed', (job, err) => {
51+
this.logger.error(
52+
`Webhook job ${job?.id ?? 'unknown'} failed permanently: ${String(err)}`,
53+
)
54+
})
55+
56+
this.logger.log('Webhook worker started')
57+
}
58+
59+
async onModuleDestroy(): Promise<void> {
60+
await this.worker.close()
61+
this.logger.log('Webhook worker closed')
62+
}
63+
64+
private async process({ taskId }: WebhookJobData): Promise<void> {
65+
// Always re-fetch from DB: job data is enqueued at result-submission time
66+
// but the DB state is the authoritative source of truth.
67+
const task = await this.tasksRepo.findById(taskId)
68+
69+
if (!task) {
70+
// Task was deleted between enqueue and now (e.g. retention sweep).
71+
this.logger.warn(`Webhook skipped: task ${taskId} no longer exists`)
72+
return
73+
}
74+
75+
if (!task.webhookUrl) {
76+
this.logger.warn(`Webhook skipped: task ${taskId} has no webhookUrl`)
77+
return
78+
}
79+
80+
const payload: Record<string, unknown> = {
81+
taskId: task.id,
82+
status: task.status,
83+
...(task.result !== null ? { result: task.result } : {}),
84+
...(task.error !== null ? { error: task.error } : {}),
85+
}
86+
87+
try {
88+
await this.httpRetry.post(
89+
task.webhookUrl,
90+
payload,
91+
{},
92+
{ attempts: 5, baseDelayMs: 1_000, maxDelayMs: 60_000 },
93+
)
94+
this.logger.log(`Webhook delivered for taskId=${taskId} to ${task.webhookUrl}`)
95+
} catch (err) {
96+
if (err instanceof NonRetryableError) {
97+
// 4xx from the webhookUrl endpoint — client-side misconfiguration, pointless to retry.
98+
this.logger.error(
99+
`Webhook delivery rejected (non-retryable) for taskId=${taskId}: ${err.message}`,
100+
)
101+
return
102+
}
103+
104+
if (err instanceof DispatchFailedError) {
105+
// All retry attempts exhausted — webhook_delivery_failed event for downstream alerting.
106+
this.logger.error(
107+
`webhook_delivery_failed taskId=${taskId} url=${task.webhookUrl}: ${err.message}`,
108+
)
109+
return
110+
}
111+
112+
// Unexpected error (e.g. DB outage on re-fetch) — re-throw to retry the job.
113+
throw err
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)