import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit, } from "@nestjs/common"; import { and, eq, isNull, lte, or, type SQL } from "drizzle-orm"; import type { OutboxRow, OutboxTable } from "./schema.js"; import { OUTBOX_CONFIG, OUTBOX_DB, OUTBOX_KAFKA_PRODUCER, OUTBOX_LOGGER, OUTBOX_TABLE, type OutboxConfig, type OutboxDbClient, type OutboxKafkaProducer, type OutboxLogger, } from "./types.js"; /** * OutboxPublisher —— 轮询 pending 记录并投递到 Kafka。 * * 实现 at-least-once 投递语义: * 1. 周期性(pollIntervalMs)批量拉取 status=pending 且到期的记录 * 2. 逐条投递到 Kafka(message key = event id,保证同聚合有序) * 3. 投递成功 → 标记 published * 4. 投递失败 → 指数退避更新 nextRetryAt;重试耗尽 → 标记 failed * * 幂等性: * - producer 应由调用方配置为 idempotent(`kafka.producer({ idempotent: true, transactionalId })`), * 避免生产端重试产生重复消息 * - message key = event id(cuid2),消费端可基于 event_id 去重(Redis SETNX 或 DB 唯一索引) */ @Injectable() export class OutboxPublisher implements OnModuleInit, OnModuleDestroy { private intervalId: ReturnType | null = null; private isPolling = false; constructor( @Inject(OUTBOX_DB) private readonly db: OutboxDbClient, @Inject(OUTBOX_TABLE) private readonly table: OutboxTable, @Inject(OUTBOX_KAFKA_PRODUCER) private readonly producer: OutboxKafkaProducer, @Inject(OUTBOX_CONFIG) private readonly config: OutboxConfig, @Inject(OUTBOX_LOGGER) private readonly logger: OutboxLogger, ) {} async onModuleInit(): Promise { await this.start(); } async onModuleDestroy(): Promise { await this.stop(); } async start(): Promise { if (this.intervalId) return; this.logger.info( { topic: this.config.kafkaTopic, pollIntervalMs: this.config.pollIntervalMs, batchSize: this.config.batchSize, }, "OutboxPublisher started", ); this.intervalId = setInterval(() => { void this.poll(); }, this.config.pollIntervalMs); } async stop(): Promise { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.logger.info("OutboxPublisher stopped"); } /** * 拉取一批到期 pending 记录并逐条投递。 * 通过 isPolling 标志保证不会并发轮询。 */ private async poll(): Promise { if (this.isPolling) return; this.isPolling = true; try { const now = new Date(); const where: SQL | undefined = and( eq(this.table.status, "pending"), or(isNull(this.table.nextRetryAt), lte(this.table.nextRetryAt, now)), ); const messages: OutboxRow[] = await this.db .select() .from(this.table) .where(where) .limit(this.config.batchSize); for (const message of messages) { await this.dispatch(message); } } catch (error) { this.logger.error({ error }, "Outbox poll failed"); } finally { this.isPolling = false; } } /** * 投递单条记录到 Kafka 并更新状态。 */ private async dispatch(message: OutboxRow): Promise { try { await this.producer.send({ topic: this.config.kafkaTopic, messages: [ { key: message.id, value: message.payload, headers: this.buildHeaders(message), }, ], }); await this.db .update(this.table) .set({ status: "published", publishedAt: new Date(), lastError: null }) .where(eq(this.table.id, message.id)); this.logger.info( { id: message.id, eventType: message.eventType, topic: this.config.kafkaTopic, }, "Outbox message published", ); } catch (error) { await this.handleFailure(message, this.toErrorMessage(error)); } } /** * 失败处理:指数退避更新 nextRetryAt,重试耗尽则标记 failed。 */ private async handleFailure( message: OutboxRow, errorMessage: string, ): Promise { const newRetryCount = message.retryCount + 1; this.logger.error( { id: message.id, eventType: message.eventType, retryCount: newRetryCount, error: errorMessage, }, "Outbox publish failed", ); if (newRetryCount >= message.maxRetryCount) { await this.db .update(this.table) .set({ status: "failed", retryCount: newRetryCount, lastError: errorMessage, }) .where(eq(this.table.id, message.id)); return; } const backoffMs = this.config.retryBackoffMs * 2 ** newRetryCount; const nextRetryAt = new Date(Date.now() + backoffMs); await this.db .update(this.table) .set({ retryCount: newRetryCount, nextRetryAt, lastError: errorMessage, }) .where(eq(this.table.id, message.id)); } /** * 构建 Kafka 消息头:eventId / eventType / aggregateId + 调用方 metadata。 */ private buildHeaders(message: OutboxRow): Record { const headers: Record = { eventId: message.id, eventType: message.eventType, aggregateId: message.aggregateId, }; if (message.metadata) { for (const [key, value] of Object.entries(message.metadata)) { headers[key] = value; } } return headers; } private toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } }