Files
Edu/packages/shared-ts/src/outbox/publisher.ts
SpecialX faaaf29f67 docs: ai 协作文档体系重构与多 ai 仲裁结果落地
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.设计规格文档
2026-07-10 12:58:22 +08:00

206 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}