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.设计规格文档
This commit is contained in:
205
packages/shared-ts/src/outbox/publisher.ts
Normal file
205
packages/shared-ts/src/outbox/publisher.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user