refactor(shared-ts,iam,core-edu,content,msg): remove outbox polling publisher
M8: Debezium CDC now handles outbox table to Kafka (ADR-032) - Remove OutboxPublisher class from shared-ts - Remove publisher from iam/core-edu/content/msg lifecycle and modules - OutboxService retained for transactional outbox table writes - Debezium monitors binlog and pushes to Kafka automatically
This commit is contained in:
@@ -1,20 +1,17 @@
|
||||
export { OutboxModule } from "./outbox.module.js";
|
||||
export type { OutboxRootOptions } from "./outbox.module.js";
|
||||
export { OutboxService } from "./outbox.service.js";
|
||||
export { OutboxPublisher } from "./publisher.js";
|
||||
export { createOutboxTable } from "./schema.js";
|
||||
export type { OutboxTable, OutboxRow, NewOutboxRow } from "./schema.js";
|
||||
export {
|
||||
OUTBOX_CONFIG,
|
||||
OUTBOX_DB,
|
||||
OUTBOX_KAFKA_PRODUCER,
|
||||
OUTBOX_LOGGER,
|
||||
OUTBOX_TABLE,
|
||||
} from "./types.js";
|
||||
export type {
|
||||
OutboxConfig,
|
||||
OutboxDbClient,
|
||||
OutboxKafkaProducer,
|
||||
OutboxLogger,
|
||||
OutboxRecord,
|
||||
OutboxStatus,
|
||||
|
||||
@@ -8,34 +8,29 @@ type PinoFn = (options?: LoggerOptions | unknown) => PinoLogger;
|
||||
const pino = ((pinoNs as unknown as { default: PinoFn }).default ??
|
||||
(pinoNs as unknown as PinoFn)) as PinoFn;
|
||||
import { OutboxService } from "./outbox.service.js";
|
||||
import { OutboxPublisher } from "./publisher.js";
|
||||
import { createOutboxTable } 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";
|
||||
|
||||
/**
|
||||
* forRoot 选项。
|
||||
*
|
||||
* `config` 即 OutboxConfig(表名 / topic / 轮询参数)。
|
||||
* `db` / `kafkaProducer` / `logger` 为基础设施实例:NestJS DynamicModule
|
||||
* `config` 即 OutboxConfig(表名 / topic / maxRetryCount)。
|
||||
* `db` / `logger` 为基础设施实例:NestJS DynamicModule
|
||||
* 无法跨模块边界注入宿主已存在的具体单例,故统一通过 forRoot 注入。
|
||||
*
|
||||
* `kafkaProducer` 应由调用方以 idempotent 方式创建:
|
||||
* `kafka.producer({ idempotent: true, transactionalId: '<service>-tx' })`。
|
||||
* v2.1(M8):投递由 Debezium CDC 接管,`kafkaProducer` 参数已移除。
|
||||
*/
|
||||
export interface OutboxRootOptions {
|
||||
config: OutboxConfig;
|
||||
db: OutboxDbClient;
|
||||
kafkaProducer: OutboxKafkaProducer;
|
||||
/** 可选;省略时使用默认 pino logger(name=outbox) */
|
||||
logger?: OutboxLogger;
|
||||
}
|
||||
@@ -47,14 +42,16 @@ function resolveLogger(logger: OutboxLogger | undefined): OutboxLogger {
|
||||
/**
|
||||
* OutboxModule —— 事务性 Outbox 模式的 NestJS DynamicModule。
|
||||
*
|
||||
* 注册并启动:
|
||||
* 注册:
|
||||
* - OutboxService:业务代码注入后调用 `publish` 写入 outbox 记录
|
||||
* - OutboxPublisher:onModuleInit 启动轮询,onModuleDestroy 停止轮询
|
||||
*
|
||||
* v2.1(M8):OutboxPublisher 轮询线程已移除,outbox 表的 binlog 由
|
||||
* Debezium 监控并投递到 Kafka(Transaction Log Tailing)。
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* @Module({
|
||||
* imports: [OutboxModule.forRoot({ config, db, kafkaProducer })],
|
||||
* imports: [OutboxModule.forRoot({ config, db })],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
@@ -72,10 +69,8 @@ export class OutboxModule {
|
||||
{ provide: OUTBOX_CONFIG, useValue: options.config },
|
||||
{ provide: OUTBOX_DB, useValue: options.db },
|
||||
{ provide: OUTBOX_TABLE, useValue: table },
|
||||
{ provide: OUTBOX_KAFKA_PRODUCER, useValue: options.kafkaProducer },
|
||||
{ provide: OUTBOX_LOGGER, useValue: logger },
|
||||
OutboxService,
|
||||
OutboxPublisher,
|
||||
],
|
||||
exports: [OutboxService],
|
||||
};
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
* 调用方在业务事务内调用 `publish`,将事件记录写入 outbox 表,
|
||||
* 与业务写在同一事务中原子提交,保证“业务变更”与“事件发布”的一致性。
|
||||
*
|
||||
* 由独立的 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafka,
|
||||
* 实现 at-least-once 投递语义。
|
||||
* v2.1(M8):投递由 Debezium CDC 接管——Debezium 监控 MySQL binlog,
|
||||
* 将 outbox 表的新增记录投递到 Kafka,实现 at-least-once 投递语义。
|
||||
* OutboxService 仅负责写入 outbox 表,不再直接调用 Kafka producer。
|
||||
*/
|
||||
@Injectable()
|
||||
export class OutboxService {
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
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<typeof setInterval> | 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<void> {
|
||||
await this.start();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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<void> {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.logger.info("OutboxPublisher stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取一批到期 pending 记录并逐条投递。
|
||||
* 通过 isPolling 标志保证不会并发轮询。
|
||||
*/
|
||||
private async poll(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,27 @@
|
||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||
import type { Producer } from "kafkajs";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
/**
|
||||
* Outbox 记录状态机:
|
||||
* - pending:已写入 outbox 表,等待 publisher 投递
|
||||
* - published:已成功投递到 Kafka
|
||||
* - pending:已写入 outbox 表,等待 Debezium 投递
|
||||
* - published:已成功投递到 Kafka(由 Debezium/CDC 标记)
|
||||
* - failed:重试次数耗尽,需人工介入
|
||||
*/
|
||||
export type OutboxStatus = "pending" | "published" | "failed";
|
||||
|
||||
/**
|
||||
* Outbox 模块配置(由各服务在 forRoot 时提供)。
|
||||
*
|
||||
* v2.1(M8):投递由 Debezium CDC 接管,轮询相关配置(pollIntervalMs /
|
||||
* batchSize / retryBackoffMs)已移除。
|
||||
*/
|
||||
export interface OutboxConfig {
|
||||
/** outbox 表名(每服务独立,如 core_edu_outbox) */
|
||||
tableName: string;
|
||||
/** Kafka 投递目标 topic */
|
||||
/** Kafka 投递目标 topic(仅供 Debezium 路由参考,OutboxService 不直接使用) */
|
||||
kafkaTopic: string;
|
||||
/** 轮询 pending 记录的间隔(毫秒) */
|
||||
pollIntervalMs: number;
|
||||
/** 每次轮询批量处理的记录数 */
|
||||
batchSize: number;
|
||||
/** 单条记录最大重试次数 */
|
||||
/** 单条记录最大重试次数(写入 outbox 行的 max_retry_count 列) */
|
||||
maxRetryCount: number;
|
||||
/** 重试退避基数(毫秒),实际退避 = retryBackoffMs * 2^retryCount */
|
||||
retryBackoffMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +69,6 @@ export interface OutboxRecord {
|
||||
*/
|
||||
export type OutboxDbClient = MySql2Database<Record<string, never>>;
|
||||
|
||||
/** Kafka producer 类型别名 */
|
||||
export type OutboxKafkaProducer = Producer;
|
||||
|
||||
/** pino Logger 类型别名 */
|
||||
export type OutboxLogger = Logger;
|
||||
|
||||
@@ -83,5 +76,4 @@ export type OutboxLogger = Logger;
|
||||
export const OUTBOX_CONFIG = Symbol("OUTBOX_CONFIG");
|
||||
export const OUTBOX_DB = Symbol("OUTBOX_DB");
|
||||
export const OUTBOX_TABLE = Symbol("OUTBOX_TABLE");
|
||||
export const OUTBOX_KAFKA_PRODUCER = Symbol("OUTBOX_KAFKA_PRODUCER");
|
||||
export const OUTBOX_LOGGER = Symbol("OUTBOX_LOGGER");
|
||||
|
||||
Reference in New Issue
Block a user