Files
photo-live/services/worker/test/publish.e2e-spec.ts
T
浅念 94357e9f03 feat(worker): publish transaction with outbox event and compensating sweep
Task 21: PublishService.processPhoto streams the original from storage,
runs the sharp pipeline, puts thumb/browse variants under
photo-live/dev/albums/{albumId}/variants/{photoId}/1/, then commits ONE
Prisma transaction: photo_variants upserts (thumb+browse), a conditional
photos update WHERE processingState IN (uploaded, processing_failed) that
flips to ready+published, stamps publishedAt/width/height, clears
errorCode and bumps entityVersion, and the outbox_events row
(photo.published, payload = contracts PhotoPublishedPayload with the
post-increment entityVersion). A zero-row conditional update means a
concurrent winner already published -> idempotent 'already' with no
second event and no extra version bump. The BullMQ worker wrapper
(QUEUE_PHOTO_PROCESS, concurrency 2, jobId 'process-{photoId}' without
the reserved ':') claims photo_jobs pending->active (attempt++), marks
done for published/already/skipped alike, and rethrows pipeline/storage
errors for the producer-configured 3-attempt exponential backoff; the
final attempt (BullMQ increments attemptsMade only after
moveToFinished, so in-processor it is 0-based: current = attemptsMade+1)
marks photo_jobs failed + photo processing_failed with the
PipelineError code while visibility stays unpublished. sweepOnce()
claims due pending rows (nextRetryAt IS NULL OR < now) and re-enqueues
stale (>5min) active rows with jobId dedupe; startSweepInterval(30s)
boot-scans then loops, and main.ts wires pino logging, dedicated redis
connections and graceful SIGINT/SIGTERM shutdown.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 23:21:57 +08:00

425 lines
17 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.
// services/worker/test/publish.e2e-spec.ts —— Task 21 发布事务/补偿扫描 e2e(先写后实现)。
// 真实 MySQL(Prisma 直种 album/photos/photo_jobs)+ 真实 MinIO(storage.put 真
// JPEG 原图,变体 head 复核)+ 真实 Redis(BullMQ)。
// 分层驱动以规避 BullMQ 时序抖动:核心断言直接调 PublishService/sweepOnce;
// Worker 包装经一组真实入队用例覆盖(happy / 终败 / 跳过)。
// 覆盖:
// ① 发布事务:变体对象 + photo_variants(2) + photos(ready/published/尺寸/
// entityVersion+1) + outbox(photo.published,payload=契约字段全集) 单事务一致;
// ② 重复处理幂等:无二次变体/事件,entityVersion 不再自增;
// ③ 状态不可处理(pending_upload)→ 跳过;
// ④ sweepOnce:到期 pending(nextRetryAt 过期 / NULL)认领入队,done 不再认领,
// 僵尸 active(updatedAt>5min)重入队;
// ⑤ 真实 Worker:happy 路径 done;坏文件 3 次指数退避后 photo processing_failed
// (visibility 仍 unpublished)+ job failed(attempt=3);pending_upload 跳过置 done。
import { randomBytes } from 'node:crypto';
import { Queue, type Worker } from 'bullmq';
import IORedis from 'ioredis';
import type { Logger } from 'pino';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import type { PhotoPublishedPayload } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import {
PROCESS_JOB_ATTEMPTS,
PROCESS_JOB_BACKOFF,
PHOTO_PROCESS_JOB_NAME,
createPhotoProcessWorker,
photoProcessJobId,
type PhotoProcessJobData,
type ProcessOutcome,
} from '../src/process.processor';
import { PublishService } from '../src/publish.service';
import { sweepOnce } from '../src/sweep';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 轮询等待:e2e 对 BullMQ 异步路径的断言前置。 */
async function waitFor(
check: () => Promise<boolean>,
timeoutMs = 30_000,
stepMs = 100,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await check()) return;
if (Date.now() > deadline) {
throw new Error(`waitFor 超时(${timeoutMs}ms)`);
}
await new Promise((resolve) => setTimeout(resolve, stepMs));
}
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 测试内生成真实 JPEG(无二进制入库)。 */
async function makeJpeg(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 180, g: 120, b: 60 } },
})
.jpeg({ quality: 85 })
.toBuffer();
}
describe('publish (e2e)', () => {
let prisma: PrismaClient;
let storage: MinioStorageAdapter;
let publish: PublishService;
let queue: Queue;
let queueConnection: IORedis;
const KEY_ROOT = 'photo-live/dev';
interface SeedResult {
albumId: string;
photoId: string;
objectKey: string;
}
/** 直种 album + photo(+可选原图对象) + photo_jobs(pending)。 */
async function seedPhoto(
alias: string,
options: {
state?: string;
jobState?: string;
nextRetryAt?: Date | null;
updatedAt?: Date;
original?: Buffer | null; // null = 不写对象(或调用方自写)
} = {},
): Promise<SeedResult> {
const suffix = randomBytes(4).toString('hex');
const albumId = (
await prisma.albums.create({
data: {
publicToken: `pt-${alias}-${suffix}`,
ownerId: `u-${alias}`,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
})
).id;
const photoId = cuidLike(alias);
const objectKey = `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`;
const original =
options.original === undefined
? await makeJpeg(800, 500)
: options.original;
if (original !== null) {
await storage.put(objectKey, original, 'image/jpeg');
}
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-${alias}`,
objectKey,
bytes: original?.length ?? 0,
processingState: options.state ?? 'uploaded',
},
});
await prisma.photo_jobs.create({
data: {
photoId,
type: PHOTO_PROCESS_JOB_NAME,
state: options.jobState ?? 'pending',
nextRetryAt: options.nextRetryAt ?? null,
...(options.updatedAt !== undefined ? { updatedAt: options.updatedAt } : {}),
},
});
return { albumId, photoId, objectKey };
}
/** 变体对象键(与 src/publish.service.ts 的 variantsObjectKey 同布局的断言字面量)。 */
const variantKey = (albumId: string, photoId: string, variant: string) =>
`${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
beforeAll(async () => {
await resetDb();
prisma = new PrismaClient();
storage = createStorageFromEnv();
publish = new PublishService(prisma, storage);
queueConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queueConnection.on('error', () => undefined); // 测试环境不崩进程
queue = new Queue('photo-process', { connection: queueConnection });
});
afterAll(async () => {
await queue.obliterate({ force: true }).catch(() => undefined);
await queue.close().catch(() => undefined);
await queueConnection.quit().catch(() => undefined);
await prisma.$disconnect();
});
describe('PublishService.processPhoto(直接驱动)', () => {
test('发布事务:变体上传 + 三表单事务一致(photos/photo_variants/outbox)', async () => {
const { albumId, photoId, objectKey } = await seedPhoto('pub1');
const outcome = await publish.processPhoto(photoId);
expect(outcome).toBe('published');
// MinIO:thumb/browse 真实存在(head 复核,jpeg + 非零字节)。
const thumbHead = await storage.head(variantKey(albumId, photoId, 'thumb'));
const browseHead = await storage.head(variantKey(albumId, photoId, 'browse'));
expect(thumbHead).not.toBeNull();
expect(browseHead).not.toBeNull();
expect(thumbHead?.contentType).toBe('image/jpeg');
expect(thumbHead?.bytes).toBeGreaterThan(0);
expect(browseHead?.bytes).toBeGreaterThan(0);
// photos:ready/published/publishedAt/尺寸/entityVersion 1→2。
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('ready');
expect(photo.visibility).toBe('published');
expect(photo.publishedAt).not.toBeNull();
expect(photo.width).toBe(800);
expect(photo.height).toBe(500);
expect(photo.entityVersion).toBe(2);
expect(photo.errorCode).toBeNull();
// photo_variants:两行,键/尺寸/版本一致(800x500 → thumb 480x300,
// browse 不放大 800x500)。
const variants = await prisma.photo_variants.findMany({
where: { photoId },
orderBy: { variant: 'asc' }, // browse < thumb(字典序)
});
expect(variants).toHaveLength(2);
const [browseRow, thumbRow] = variants;
expect(thumbRow).toMatchObject({
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
version: 1,
width: 480,
height: 300,
});
expect(thumbRow.bytes).toBe(thumbHead?.bytes);
expect(browseRow).toMatchObject({
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
version: 1,
width: 800,
height: 500,
});
expect(browseRow.bytes).toBe(browseHead?.bytes);
// outbox:类型/entityVersion 对齐 photo,payload = 契约 PhotoPublishedPayload 全集。
const event = await prisma.outbox_events.findFirstOrThrow({
where: { photoId },
});
expect(event.type).toBe('photo.published');
expect(event.albumId).toBe(albumId);
expect(event.entityVersion).toBe(2);
expect(event.occurredAt.getTime()).toBe(photo.publishedAt?.getTime());
const expectedPayload: PhotoPublishedPayload = {
photoId,
albumId,
objectKey,
thumbKey: variantKey(albumId, photoId, 'thumb'),
browseKey: variantKey(albumId, photoId, 'browse'),
width: 800,
height: 500,
publishedAt: (photo.publishedAt as Date).toISOString(),
entityVersion: 2,
};
expect(event.payload).toEqual(expectedPayload);
// 全库仅此一条事件(无重复/孤儿)。
expect(await prisma.outbox_events.count()).toBe(1);
});
test('重复处理幂等:无二次变体/事件,entityVersion 不再自增', async () => {
const { photoId } = await seedPhoto('pub2');
expect(await publish.processPhoto(photoId)).toBe('published');
const before = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
const again = await publish.processPhoto(photoId);
expect(again).toBe('already');
const after = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(after.entityVersion).toBe(before.entityVersion);
expect(after.publishedAt?.getTime()).toBe(before.publishedAt?.getTime());
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(2);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(1);
});
test('状态不可处理(pending_upload)→ 跳过,不留任何处理痕迹', async () => {
const { photoId } = await seedPhoto('pub3', { state: 'pending_upload' });
expect(await publish.processPhoto(photoId)).toBe('skipped');
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('pending_upload');
expect(photo.visibility).toBe('unpublished');
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
});
describe('sweepOnce(直接驱动,无 Worker 在跑)', () => {
test('认领到期 pending(过期/NULL)并重排,done 不认领,僵尸 active 重排', async () => {
// 前置:直驱用例留下的 pending job 行(照片其实已处理)先置 done——
// 既贴近真实(api 侧 job 随处理闭环),也让本用例的认领计数可精确断言。
await prisma.photo_jobs.updateMany({
where: { state: 'pending' },
data: { state: 'done' },
});
const past = await seedPhoto('sw-a', {
state: 'pending_upload',
nextRetryAt: new Date(Date.now() - 60_000),
}); // ① nextRetryAt 过期
const nullRetry = await seedPhoto('sw-b', { state: 'pending_upload' }); // ② NULL
const done = await seedPhoto('sw-c', {
state: 'pending_upload',
jobState: 'done',
}); // done:不再认领
const stale = await seedPhoto('sw-d', {
state: 'pending_upload',
jobState: 'active',
updatedAt: new Date(Date.now() - 6 * 60_000),
}); // 僵尸 active(>5min)
const result = await sweepOnce({ prisma, queue });
expect(result.claimed).toBe(2);
expect(result.enqueueFailures).toBe(0);
expect(result.staleReEnqueued).toBe(1);
// DB:pending→active(过期与 NULL 都认领);done 不动;active 保持。
const jobs = await prisma.photo_jobs.findMany({
where: { photoId: { in: [past.photoId, nullRetry.photoId, done.photoId, stale.photoId] } },
});
const stateOf = (photoId: string) =>
jobs.find((j) => j.photoId === photoId)?.state;
expect(stateOf(past.photoId)).toBe('active');
expect(stateOf(nullRetry.photoId)).toBe('active');
expect(stateOf(done.photoId)).toBe('done');
expect(stateOf(stale.photoId)).toBe('active');
// BullMQ:认领/重排的 jobId 已入队;done 的 jobId 不存在。
expect(await queue.getJob(photoProcessJobId(past.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(nullRetry.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(stale.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(done.photoId))).toBeUndefined();
// 清空队列,后续真实 Worker 用例从干净状态开始。
await queue.obliterate({ force: true });
});
});
describe('真实 BullMQ Worker', () => {
let worker: Worker<PhotoProcessJobData, ProcessOutcome, string>;
let workerConnection: IORedis;
const logger = console as unknown as Logger; // e2e 直观日志
beforeAll(async () => {
workerConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
workerConnection.on('error', () => undefined);
worker = createPhotoProcessWorker({
prisma,
storage,
connection: workerConnection,
logger,
});
});
afterAll(async () => {
await worker.close();
await workerConnection.quit().catch(() => undefined);
});
/** 与 api 生产者同参入队(jobId 去重 + attempts:3 指数退避 1s 起)。 */
const enqueue = (photoId: string) =>
queue.add(
PHOTO_PROCESS_JOB_NAME,
{ photoId },
{
jobId: photoProcessJobId(photoId),
attempts: PROCESS_JOB_ATTEMPTS,
backoff: PROCESS_JOB_BACKOFF,
},
);
test('happy:真实入队 → 处理 → 发布事务 + photo_jobs done', async () => {
const { albumId, photoId } = await seedPhoto('wk-ok');
await enqueue(photoId);
await waitFor(async () =>
(await prisma.photos.findUnique({ where: { id: photoId } }))?.processingState === 'ready',
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.visibility).toBe('published');
expect(photo.entityVersion).toBe(2);
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(2);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(1);
expect(
await storage.head(variantKey(albumId, photoId, 'thumb')),
).not.toBeNull();
const job = await prisma.photo_jobs.findUniqueOrThrow({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
});
expect(job.state).toBe('done');
expect(job.attempt).toBe(1);
});
test('坏文件:3 次退避重试后终败,photo processing_failed 且不公开', async () => {
const corrupt = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
randomBytes(8192),
]);
const { photoId } = await seedPhoto('wk-bad', { original: corrupt });
await enqueue(photoId);
await waitFor(
async () =>
(await prisma.photo_jobs.findUnique({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
}))?.state === 'failed',
60_000,
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('processing_failed');
expect(photo.errorCode).toBe('DECODE_FAILED');
expect(photo.visibility).toBe('unpublished'); // 失败不公开
expect(photo.publishedAt).toBeNull();
const job = await prisma.photo_jobs.findUniqueOrThrow({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
});
expect(job.state).toBe('failed');
expect(job.errorCode).toBe('DECODE_FAILED');
expect(job.attempt).toBe(3);
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
test('pending_upload 跳过:job 置 done,照片零处理', async () => {
const { photoId } = await seedPhoto('wk-skip', { state: 'pending_upload' });
await enqueue(photoId);
await waitFor(
async () =>
(await prisma.photo_jobs.findUnique({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
}))?.state === 'done',
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('pending_upload');
expect(photo.visibility).toBe('unpublished');
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
});
});