Files
photo-live/services/worker/src/sweep.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

133 lines
4.2 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/src/sweep.ts —— photo_jobs 补偿扫描(Task 21,总纲 §5.6)。
// photo_jobs(pending) 是持久事实源:api complete 事务提交后入队失败(Redis 抖动/
// 宕机)留下孤儿 pending,sweep 周期性认领并重新入队。两类认领:
// ① state='pending' AND (nextRetryAt IS NULL OR nextRetryAt < now):
// NULL 语义 = 立即可投递(api complete 新建的行不带 nextRetryAt);
// ② state='active' AND updatedAt < now-5min:worker 认领后崩溃/重启留下的
// 僵尸 active(BullMQ stalled 恢复之外的 DB 侧兜底),只重入队不改状态
// (jobId 去重:BullMQ 里仍活跃的 job 不会重复执行)。
// 入队失败仅记日志(下一轮 sweep 自然重试);done/failed 不再认领。
import type { Queue } from 'bullmq';
import type { PrismaClient } from '@photo/db-cn';
import type { Logger } from 'pino';
import {
PHOTO_PROCESS_JOB_NAME,
PROCESS_JOB_ATTEMPTS,
PROCESS_JOB_BACKOFF,
photoProcessJobId,
} from './process.processor';
/** active 僵尸阈值:超过该时长未更新的 active 视为 worker 已失联。 */
const STALE_ACTIVE_MS = 5 * 60_000;
export interface SweepDeps {
prisma: PrismaClient;
queue: Queue;
logger?: Logger;
}
export interface SweepResult {
/** 认领为 active 的到期 pending 行数。 */
claimed: number;
/** 因入队失败未补偿成功的行数(下一轮重试)。 */
enqueueFailures: number;
/** 僵尸 active 重新入队行数。 */
staleReEnqueued: number;
}
async function enqueueProcess(deps: SweepDeps, photoId: string): Promise<boolean> {
try {
await deps.queue.add(
PHOTO_PROCESS_JOB_NAME,
{ photoId },
{
jobId: photoProcessJobId(photoId),
attempts: PROCESS_JOB_ATTEMPTS,
backoff: PROCESS_JOB_BACKOFF,
},
);
return true;
} catch (err) {
deps.logger?.error(
{ photoId, err: err instanceof Error ? err.message : String(err) },
'sweep 入队失败(下一轮补偿)',
);
return false;
}
}
/** 单轮扫描:可反复调用(e2e 直接驱动);幂等——已入队 jobId 会被 BullMQ 去重。 */
export async function sweepOnce(deps: SweepDeps): Promise<SweepResult> {
const now = new Date();
// ① 到期 pending:IS NULL(立即可投递)或到期重试。
const due = await deps.prisma.photo_jobs.findMany({
where: {
state: 'pending',
type: PHOTO_PROCESS_JOB_NAME,
OR: [{ nextRetryAt: null }, { nextRetryAt: { lt: now } }],
},
select: { photoId: true },
});
let claimed = 0;
let enqueueFailures = 0;
if (due.length > 0) {
claimed = (
await deps.prisma.photo_jobs.updateMany({
where: {
photoId: { in: due.map((row) => row.photoId) },
type: PHOTO_PROCESS_JOB_NAME,
state: 'pending', // 重检:并发 sweep 恰一方胜出
},
data: { state: 'active' },
})
).count;
for (const row of due) {
if (!(await enqueueProcess(deps, row.photoId))) {
enqueueFailures += 1;
}
}
}
// ② 僵尸 active:仅重入队(jobId 去重),不改 DB 状态。
const stale = await deps.prisma.photo_jobs.findMany({
where: {
state: 'active',
updatedAt: { lt: new Date(Date.now() - STALE_ACTIVE_MS) },
},
select: { photoId: true },
});
let staleReEnqueued = 0;
for (const row of stale) {
if (await enqueueProcess(deps, row.photoId)) {
staleReEnqueued += 1;
} else {
enqueueFailures += 1;
}
}
deps.logger?.info({ claimed, enqueueFailures, staleReEnqueued }, 'sweep 完成');
return { claimed, enqueueFailures, staleReEnqueued };
}
/**
* 周期扫描(main 以 30s 启动):启动即扫一轮(进程重启后立刻补偿积压),随后
* 固定间隔。返回 stop() 供优雅退出。
*/
export function startSweepInterval(
intervalMs: number,
deps: SweepDeps,
): { stop: () => void } {
const run = () => {
void sweepOnce(deps).catch((err: unknown) => {
deps.logger?.error(
{ err: err instanceof Error ? err.message : String(err) },
'sweep 轮次异常',
);
});
};
run();
const timer = setInterval(run, intervalMs);
return { stop: () => clearInterval(timer) };
}