Commit fa58bdf6 by luoqi

test(sync): 僵尸锁回收判据改为注入时间界 —— 去掉对真实时钟的依赖

sync-lock-reap.spec.ts 在整套 jest 并行跑、机器同时有重负载时间歇失败:
该套件平时约 5 秒,失败时耗到 68 秒。

根因不是逻辑错,是判据两端取的时间**不同源**:
  · 界   = PROCESS_STARTED_AT,模块加载时刻 T0
  · 造行 = `new Date(Date.now() - 1000)`,用例执行时刻 T0+Δ 减 1 秒
要判成僵尸需要 Δ < 1s;负载高时模块加载到用例执行的间隔超过 1 秒,
行反而落到界**之后**、不再算僵尸 → findMany 空 → updateMany 没被调
→ `updateMany.mock.calls[0][0]` 直接抛。

修法:把时间界作参数注入 reapStaleRunningLocks(默认仍是 PROCESS_STARTED_AT,
**生产行为一字未变** —— onModuleInit 那个调用点不传参),测试用固定基准
构造 before()/after(),全程不碰真实时钟。

顺带补一条边界断言:startedAt >= 界的行(并存 CLI 的真锁)连 updateMany
都不该发 —— 原来只锁了 where 用 lt 不用 gte,没锁住"真的不碰"这个行为。

 没有用调大 jest timeout 掩盖:那只是把失败推后。

验证:并行跑 `next build` 制造负载,连跑 3 次整套 857 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent dc31d486
...@@ -113,17 +113,20 @@ export class SyncIncrementalSchedulerService implements OnModuleInit { ...@@ -113,17 +113,20 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
* 正在跑的真锁(例如运维手动触发的 CLI 与本进程并存),误清会把在跑的同步" orphan"掉。 * 正在跑的真锁(例如运维手动触发的 CLI 与本进程并存),误清会把在跑的同步" orphan"掉。
* *
* 幂等:被标 failed 只是让并发锁释放;数据侧不受影响(游标没推进,下次增量靠 48h 回看窗补齐)。 * 幂等:被标 failed 只是让并发锁释放;数据侧不受影响(游标没推进,下次增量靠 48h 回看窗补齐)。
*
* @param processStartedAt 回收判据的时间界(默认本进程启动时刻)。显式传入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
*/ */
private async reapStaleRunningLocks(): Promise<void> { private async reapStaleRunningLocks(processStartedAt: Date = PROCESS_STARTED_AT): Promise<void> {
try { try {
const stale = await this.prisma.syncLog.findMany({ const stale = await this.prisma.syncLog.findMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: PROCESS_STARTED_AT } }, where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
select: { id: true, hostId: true, startedAt: true, triggeredBy: true }, select: { id: true, hostId: true, startedAt: true, triggeredBy: true },
}); });
if (stale.length === 0) return; if (stale.length === 0) return;
const { count } = await this.prisma.syncLog.updateMany({ const { count } = await this.prisma.syncLog.updateMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: PROCESS_STARTED_AT } }, where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
data: { data: {
status: SyncStatus.FAILED, status: SyncStatus.FAILED,
endedAt: new Date(), endedAt: new Date(),
......
...@@ -10,8 +10,20 @@ import { SyncIncrementalSchedulerService } from '../src/queues/sync-incremental. ...@@ -10,8 +10,20 @@ import { SyncIncrementalSchedulerService } from '../src/queues/sync-incremental.
* 修复:service 启动时把 startedAt 早于本进程启动的 running 行标 failed。 * 修复:service 启动时把 startedAt 早于本进程启动的 running 行标 failed。
* 判据安全性:sync 只在本进程 cron 或一次性 CLI 里跑,都不可能比本进程更早启动还活着; * 判据安全性:sync 只在本进程 cron 或一次性 CLI 里跑,都不可能比本进程更早启动还活着;
* 本进程启动后新建的 running(可能是并存的手动 CLI 真锁)绝不碰。 * 本进程启动后新建的 running(可能是并存的手动 CLI 真锁)绝不碰。
*
* 本用例**不碰真实时钟**:时间界(进程启动时刻)作参数注入 reapStaleRunningLocks,
* 行的 startedAt 全部相对固定基准构造。此前用 `Date.now() - 1000` 造时间差,判据里的界
* 却是模块加载时刻 —— 机器有重负载(如并行 next build)时,模块加载到用例执行的间隔
* 会超过 1s,行反而落到界之后不再算僵尸,间歇性假失败。
*/ */
/** 固定时间基准:代表"本进程启动时刻",不取真实时钟 */
const PROCESS_STARTED_AT = new Date('2026-07-23T02:00:00.000Z');
/** 早于进程启动 ms 毫秒 → 应被回收的僵尸锁 */
const before = (ms: number) => new Date(PROCESS_STARTED_AT.getTime() - ms);
/** 晚于进程启动 ms 毫秒 → 可能是并存 CLI 的真锁,绝不碰 */
const after = (ms: number) => new Date(PROCESS_STARTED_AT.getTime() + ms);
function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; triggeredBy: string }>) { function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; triggeredBy: string }>) {
const findMany = jest.fn().mockImplementation(async ({ where }) => { const findMany = jest.fn().mockImplementation(async ({ where }) => {
const lt: Date = where.startedAt.lt; const lt: Date = where.startedAt.lt;
...@@ -33,15 +45,16 @@ function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; ...@@ -33,15 +45,16 @@ function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date;
return { svc, findMany, updateMany }; return { svc, findMany, updateMany };
} }
// 触达 private 方法(纯编排,无需走 onModuleInit 以免顺带注册 cron) // 触达 private 方法(纯编排,无需走 onModuleInit 以免顺带注册 cron);时间界显式注入
const reap = (svc: SyncIncrementalSchedulerService) => const reap = (svc: SyncIncrementalSchedulerService, processStartedAt = PROCESS_STARTED_AT) =>
(svc as unknown as { reapStaleRunningLocks: () => Promise<void> }).reapStaleRunningLocks(); (
svc as unknown as { reapStaleRunningLocks: (at: Date) => Promise<void> }
).reapStaleRunningLocks(processStartedAt);
describe('reapStaleRunningLocks', () => { describe('reapStaleRunningLocks', () => {
test('回收进程启动前的 running 行 → 标 failed', async () => { test('回收进程启动前的 running 行 → 标 failed', async () => {
const old = new Date(Date.now() - 3_600_000); // 1h 前
const { svc, updateMany } = makeService([ const { svc, updateMany } = makeService([
{ id: 'zombie', hostId: 'h1', startedAt: old, triggeredBy: 'sync:jvs-dw:x' }, { id: 'zombie', hostId: 'h1', startedAt: before(3_600_000), triggeredBy: 'sync:jvs-dw:x' },
]); ]);
await reap(svc); await reap(svc);
expect(updateMany).toHaveBeenCalledTimes(1); expect(updateMany).toHaveBeenCalledTimes(1);
...@@ -53,14 +66,24 @@ describe('reapStaleRunningLocks', () => { ...@@ -53,14 +66,24 @@ describe('reapStaleRunningLocks', () => {
}); });
test('⭐ 只回收 startedAt < 进程启动时刻的行 —— where 用 lt,不用 gte', async () => { test('⭐ 只回收 startedAt < 进程启动时刻的行 —— where 用 lt,不用 gte', async () => {
// 判据锁:where 必须是 { startedAt: { lt: <某时刻> } },否则会误杀本进程启动后的真锁 // 判据锁:where 必须是 { startedAt: { lt: 进程启动时刻 } },否则会误杀本进程启动后的真锁
const { svc, findMany, updateMany } = makeService([ const { svc, findMany, updateMany } = makeService([
{ id: 'zombie', hostId: 'h1', startedAt: new Date(Date.now() - 1000), triggeredBy: 't' }, { id: 'zombie', hostId: 'h1', startedAt: before(1000), triggeredBy: 't' },
]); ]);
await reap(svc); await reap(svc);
expect(findMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt'); expect(findMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt', PROCESS_STARTED_AT);
expect(findMany.mock.calls[0][0].where.startedAt).not.toHaveProperty('gte'); expect(findMany.mock.calls[0][0].where.startedAt).not.toHaveProperty('gte');
expect(updateMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt'); expect(updateMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt', PROCESS_STARTED_AT);
});
test('⭐ 进程启动后新建的 running 绝不碰(并存 CLI 的真锁)', async () => {
// 边界行为锁:startedAt >= 界的行不进回收集合,连 updateMany 都不该发
const { svc, updateMany } = makeService([
{ id: 'live-cli-lock', hostId: 'h1', startedAt: after(1000), triggeredBy: 'cli:manual' },
{ id: 'boundary', hostId: 'h2', startedAt: PROCESS_STARTED_AT, triggeredBy: 'cli:manual' },
]);
await reap(svc);
expect(updateMany).not.toHaveBeenCalled();
}); });
test('无僵尸锁 → 不发 updateMany(纯净启动零副作用)', async () => { test('无僵尸锁 → 不发 updateMany(纯净启动零副作用)', async () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment