|
| 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 | +} |
0 commit comments