1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
206 lines
5.6 KiB
TypeScript
206 lines
5.6 KiB
TypeScript
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);
|
||
}
|
||
}
|