feat(M-AI-02-07): transactional OutboxEvent model
- 16 columns, dedupeKey UNIQUE, (status,availableAt) + (lockedAt) indexes - CAS markProcessing (updateMany where status='pending') - createInTransaction(tx) for atomic Job+Outbox writes - 12 unit tests (CAS, transaction, lock release, constraints) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7cc6947e41
commit
0dab626cd8
@ -0,0 +1,27 @@
|
|||||||
|
-- M-AI-02-07: 新增 Transactional Outbox 数据模型
|
||||||
|
-- 用于可靠发布 BullMQ Job 和领域事件(本批不实现 Dispatcher)
|
||||||
|
|
||||||
|
CREATE TABLE `OutboxEvent` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`eventType` VARCHAR(128) NOT NULL,
|
||||||
|
`aggregateType` VARCHAR(64) NOT NULL,
|
||||||
|
`aggregateId` VARCHAR(255) NOT NULL,
|
||||||
|
`dedupeKey` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
`attemptCount` INT NOT NULL DEFAULT 0,
|
||||||
|
`availableAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`lockedAt` DATETIME(3) NULL,
|
||||||
|
`lockedBy` VARCHAR(100) NULL,
|
||||||
|
`publishedAt` DATETIME(3) NULL,
|
||||||
|
`lastErrorCode` VARCHAR(100) NULL,
|
||||||
|
`lastErrorMessage` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `OutboxEvent_dedupeKey_key` (`dedupeKey`),
|
||||||
|
INDEX `OutboxEvent_status_availableAt_idx` (`status`, `availableAt`),
|
||||||
|
INDEX `OutboxEvent_lockedAt_idx` (`lockedAt`),
|
||||||
|
INDEX `OutboxEvent_aggregateType_aggregateId_idx` (`aggregateType`, `aggregateId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
178
src/infrastructure/outbox/outbox.repository.spec.ts
Normal file
178
src/infrastructure/outbox/outbox.repository.spec.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { OutboxRepository, CreateOutboxInput } from './outbox.repository';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
|
||||||
|
describe('OutboxRepository', () => {
|
||||||
|
let repo: OutboxRepository;
|
||||||
|
|
||||||
|
const mockOutboxCreate = jest.fn();
|
||||||
|
const mockOutboxFindMany = jest.fn();
|
||||||
|
const mockOutboxFindUnique = jest.fn();
|
||||||
|
const mockOutboxUpdate = jest.fn();
|
||||||
|
const mockOutboxUpdateMany = jest.fn();
|
||||||
|
|
||||||
|
const mockTx = {
|
||||||
|
outboxEvent: {
|
||||||
|
create: mockOutboxCreate,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockOutboxCreate.mockReset();
|
||||||
|
mockOutboxFindMany.mockReset();
|
||||||
|
mockOutboxFindUnique.mockReset();
|
||||||
|
mockOutboxUpdate.mockReset();
|
||||||
|
mockOutboxUpdateMany.mockReset();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OutboxRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
outboxEvent: {
|
||||||
|
create: mockOutboxCreate,
|
||||||
|
findMany: mockOutboxFindMany,
|
||||||
|
findUnique: mockOutboxFindUnique,
|
||||||
|
update: mockOutboxUpdate,
|
||||||
|
updateMany: mockOutboxUpdateMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repo = module.get(OutboxRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleInput: CreateOutboxInput = {
|
||||||
|
eventType: 'ai.job.completed',
|
||||||
|
aggregateType: 'AiJob',
|
||||||
|
aggregateId: 'job-1',
|
||||||
|
dedupeKey: 'job-1:completed:v1',
|
||||||
|
payload: { jobId: 'job-1', status: 'succeeded' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createInTransaction', () => {
|
||||||
|
it('应在事务中创建 Outbox 事件', async () => {
|
||||||
|
mockOutboxCreate.mockResolvedValue({ id: 'evt-1', ...sampleInput, status: 'pending', createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.createInTransaction(mockTx, sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'evt-1');
|
||||||
|
expect(mockOutboxCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('事务回滚时 Outbox 不存在(duplicate key 测试)', async () => {
|
||||||
|
// 模拟:事务中先写 Job,再写 Outbox 时遇到 dedupeKey 冲突
|
||||||
|
// 事务整体回滚,Job 也不存在
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('dup', {
|
||||||
|
code: 'P2002', clientVersion: '5.22.0', meta: {},
|
||||||
|
});
|
||||||
|
mockOutboxCreate.mockRejectedValueOnce(p2002);
|
||||||
|
|
||||||
|
await expect(repo.createInTransaction(mockTx, sampleInput)).rejects.toThrow();
|
||||||
|
// 调用方应回滚整个事务
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDispatchable', () => {
|
||||||
|
it('应返回 pending 且 availableAt 已到的事件', async () => {
|
||||||
|
mockOutboxFindMany.mockResolvedValue([]);
|
||||||
|
await repo.findDispatchable(20);
|
||||||
|
expect(mockOutboxFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'pending', availableAt: { lte: expect.any(Date) } },
|
||||||
|
orderBy: { availableAt: 'asc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markProcessing(CAS)', () => {
|
||||||
|
it('CAS 成功(status=pending)应获取锁并返回记录', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockOutboxFindUnique.mockResolvedValue({ id: 'evt-1', status: 'processing', lockedBy: 'worker-1' });
|
||||||
|
|
||||||
|
const result = await repo.markProcessing('evt-1', 'worker-1');
|
||||||
|
expect(result).toHaveProperty('status', 'processing');
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1', status: 'pending' },
|
||||||
|
data: { status: 'processing', lockedAt: expect.any(Date), lockedBy: 'worker-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CAS 失败(已被其他 Worker 抢占)应返回 null', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 0 }); // 另一个 Worker 已改 status
|
||||||
|
|
||||||
|
const result = await repo.markProcessing('evt-1', 'worker-2');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOutboxFindUnique).not.toHaveBeenCalled(); // 未获得锁,不查询
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markPublished', () => {
|
||||||
|
it('应设置 status=published, publishedAt', async () => {
|
||||||
|
mockOutboxUpdate.mockResolvedValue({ id: 'evt-1', status: 'published' });
|
||||||
|
const result = await repo.markPublished('evt-1');
|
||||||
|
expect(result).toHaveProperty('status', 'published');
|
||||||
|
expect(mockOutboxUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1' },
|
||||||
|
data: expect.objectContaining({ status: 'published' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markFailed', () => {
|
||||||
|
it('应设置 status=failed, 递增 attemptCount', async () => {
|
||||||
|
mockOutboxUpdate.mockResolvedValue({ id: 'evt-1', status: 'failed' });
|
||||||
|
const result = await repo.markFailed('evt-1', 'PUBLISH_ERR', 'timeout');
|
||||||
|
expect(result).toHaveProperty('status', 'failed');
|
||||||
|
expect(mockOutboxUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
lastErrorCode: 'PUBLISH_ERR',
|
||||||
|
lastErrorMessage: 'timeout',
|
||||||
|
attemptCount: { increment: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('releaseExpiredLocks', () => {
|
||||||
|
it('应释放超过阈值的 processing 事件锁', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 3 });
|
||||||
|
const count = await repo.releaseExpiredLocks(60_000);
|
||||||
|
expect(count).toBe(3);
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'processing', lockedAt: { lt: expect.any(Date) } },
|
||||||
|
data: { status: 'pending', lockedAt: null, lockedBy: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回 0 当无过期锁', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 0 });
|
||||||
|
const count = await repo.releaseExpiredLocks();
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('约束验证', () => {
|
||||||
|
it('不应暴露 publish/publishToQueue 方法', () => {
|
||||||
|
expect(typeof (repo as any).publish).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).publishToQueue).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).sendToBullMQ).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不应暴露独立事务方法', () => {
|
||||||
|
expect(typeof (repo as any).create).toBe('undefined');
|
||||||
|
// createInTransaction 必须接受 tx 参数,不允许无 tx 调用
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupeKey UNIQUE 由 Prisma schema 保证', () => {
|
||||||
|
// OutboxEvent.dedupeKey @unique — 数据库层保证
|
||||||
|
expect(repo).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/infrastructure/outbox/outbox.repository.ts
Normal file
139
src/infrastructure/outbox/outbox.repository.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export const OUTBOX_STATUSES = ['pending', 'processing', 'published', 'failed'] as const;
|
||||||
|
export type OutboxStatus = (typeof OUTBOX_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface CreateOutboxInput {
|
||||||
|
eventType: string;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
dedupeKey: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
availableAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-02-07: Transactional Outbox Repository
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - createInTransaction 支持在外部 Prisma Transaction 中原子写入
|
||||||
|
* - 不自行开启独立事务
|
||||||
|
* - 不向 BullMQ 发布
|
||||||
|
* - dedupeKey UNIQUE 保证幂等
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在给定的事务客户端中创建 Outbox 事件。
|
||||||
|
* 若未传 tx,使用 this.prisma(非事务模式,仅测试用)。
|
||||||
|
*
|
||||||
|
* 调用方负责在同一个 tx 中同时写入业务数据,
|
||||||
|
* 以确保 Job + Outbox 的原子性。
|
||||||
|
*/
|
||||||
|
async createInTransaction(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: CreateOutboxInput,
|
||||||
|
) {
|
||||||
|
return tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: input.eventType,
|
||||||
|
aggregateType: input.aggregateType,
|
||||||
|
aggregateId: input.aggregateId,
|
||||||
|
dedupeKey: input.dedupeKey,
|
||||||
|
payload: input.payload as any,
|
||||||
|
availableAt: input.availableAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找可分发的事件:status=pending 且 availableAt 已到。
|
||||||
|
* 按 availableAt ASC 保证 FIFO。
|
||||||
|
*/
|
||||||
|
async findDispatchable(limit: number = 50) {
|
||||||
|
return this.prisma.outboxEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'pending',
|
||||||
|
availableAt: { lte: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { availableAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为 processing(CAS:仅当 status 仍为 'pending' 时成功)。
|
||||||
|
* 返回更新后的记录;若被其他 Worker 抢占则返回 null。
|
||||||
|
*/
|
||||||
|
async markProcessing(eventId: string, lockedBy: string) {
|
||||||
|
const result = await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: eventId,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
lockedAt: new Date(),
|
||||||
|
lockedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) return null;
|
||||||
|
|
||||||
|
return this.prisma.outboxEvent.findUnique({
|
||||||
|
where: { id: eventId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记为 published */
|
||||||
|
async markPublished(eventId: string) {
|
||||||
|
return this.prisma.outboxEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date(),
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记为 failed,递增 attemptCount */
|
||||||
|
async markFailed(eventId: string, errorCode?: string, errorMessage?: string) {
|
||||||
|
return this.prisma.outboxEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
lastErrorCode: errorCode,
|
||||||
|
lastErrorMessage: errorMessage,
|
||||||
|
attemptCount: { increment: 1 },
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放过期锁:将 lockedAt 超过 thresholdMs 的 processing 事件重置为 pending。
|
||||||
|
* 返回释放数量。
|
||||||
|
*/
|
||||||
|
async releaseExpiredLocks(thresholdMs: number = 60_000) {
|
||||||
|
const cutoff = new Date(Date.now() - thresholdMs);
|
||||||
|
const result = await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
status: 'processing',
|
||||||
|
lockedAt: { lt: cutoff },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'pending',
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user