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:
SpecialX
2026-07-15 01:27:45 +08:00
parent a3f4fd013e
commit 47a062606f
16 changed files with 34 additions and 1227 deletions

View File

@@ -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,

View File

@@ -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.1M8投递由 Debezium CDC 接管,`kafkaProducer` 参数已移除。
*/
export interface OutboxRootOptions {
config: OutboxConfig;
db: OutboxDbClient;
kafkaProducer: OutboxKafkaProducer;
/** 可选;省略时使用默认 pino loggername=outbox */
logger?: OutboxLogger;
}
@@ -47,14 +42,16 @@ function resolveLogger(logger: OutboxLogger | undefined): OutboxLogger {
/**
* OutboxModule —— 事务性 Outbox 模式的 NestJS DynamicModule。
*
* 注册并启动
* 注册:
* - OutboxService业务代码注入后调用 `publish` 写入 outbox 记录
* - OutboxPublisheronModuleInit 启动轮询onModuleDestroy 停止轮询
*
* v2.1M8OutboxPublisher 轮询线程已移除outbox 表的 binlog 由
* Debezium 监控并投递到 KafkaTransaction 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],
};

View File

@@ -18,8 +18,9 @@ import {
* 调用方在业务事务内调用 `publish`,将事件记录写入 outbox 表,
* 与业务写在同一事务中原子提交,保证“业务变更”与“事件发布”的一致性。
*
* 由独立的 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafka
* 实现 at-least-once 投递语义。
* v2.1M8投递由 Debezium CDC 接管——Debezium 监控 MySQL binlog
* 将 outbox 表的新增记录投递到 Kafka实现 at-least-once 投递语义。
* OutboxService 仅负责写入 outbox 表,不再直接调用 Kafka producer。
*/
@Injectable()
export class OutboxService {

View File

@@ -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. 逐条投递到 Kafkamessage key = event id保证同聚合有序
* 3. 投递成功 → 标记 published
* 4. 投递失败 → 指数退避更新 nextRetryAt重试耗尽 → 标记 failed
*
* 幂等性:
* - producer 应由调用方配置为 idempotent`kafka.producer({ idempotent: true, transactionalId })`
* 避免生产端重试产生重复消息
* - message key = event idcuid2消费端可基于 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);
}
}

View File

@@ -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.1M8投递由 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");