133 lines
4.2 KiB
TypeScript
133 lines
4.2 KiB
TypeScript
|
|
// 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) };
|
|||
|
|
}
|