TTeamDesk 学习手册

tests/worker.integration.test.ts

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { randomUUID } from 'node:crypto';
import { Queue, QueueEvents, type WorkerOptions } from 'bullmq';
import IORedis from 'ioredis';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
import { createTaskWorker } from '@/server/jobs/worker';
import { createWorkerLogger } from '@/server/jobs/logger';
import { dispatchTasks } from '@/server/jobs/dispatcher';
import {
  queueName,
  taskJobId,
  type TaskJob,
  type createTaskQueue,
} from '@/server/jobs/queue';

function gate() {
  let open!: () => void;
  const promise = new Promise<void>((resolve) => {
    open = resolve;
  });
  return { promise, open };
}
async function within<T>(promise: Promise<T>, ms = 8000): Promise<T> {
  let timer!: ReturnType<typeof setTimeout>;
  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timer = setTimeout(
          () => reject(new Error('Worker 集成同步点超时')),
          ms,
        );
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}
function logs() {
  const records: { event: string; exhausted?: boolean }[] = [];
  const signals = new Map<string, ReturnType<typeof gate>>();
  const logger = createWorkerLogger({
    write(line) {
      const record = JSON.parse(String(line));
      records.push(record);
      signals.get(record.event)?.open();
    },
  });
  logger.level = 'trace';
  return {
    logger,
    records,
    event(name: string) {
      if (records.some((record) => record.event === name))
        return Promise.resolve();
      if (!signals.has(name)) signals.set(name, gate());
      return within(signals.get(name)!.promise);
    },
  };
}

// 两个真实 Worker 共用生产工厂、处理器与事件监听器;每例只清理自己的队列前缀。
describe.skipIf(!process.env.TEST_DATABASE_URL || !process.env.TEST_REDIS_URL)(
  '真实 Worker 的 stalled、失败回调与数据库租约',
  () => {
    const db = new PrismaClient({
      adapter: new PrismaPg({
        connectionString: process.env.TEST_DATABASE_URL ?? '',
        max: 8,
      }),
    });
    const userId = randomUUID();
    let teamId: string;
    beforeAll(async () => {
      await db.user.create({
        data: {
          id: userId,
          name: 'Worker 测试管理员',
          email: `${userId}@example.com`,
        },
      });
      teamId = (
        await db.team.create({
          data: {
            name: 'Worker 隔离团队',
            members: { create: { userId, role: 'ADMIN' } },
          },
        })
      ).id;
    });
    afterAll(async () => {
      await db.ticket.deleteMany({ where: { teamId } });
      await db.team.delete({ where: { id: teamId } });
      await db.user.delete({ where: { id: userId } });
      await db.$disconnect();
    });
    const fixture = async (kind: 'EXPORT' | 'REMINDER' = 'EXPORT') => {
      const item = await db.ticket.create({
        data: {
          teamId,
          creatorId: userId,
          title: `Worker-${randomUUID()}`,
          description: '真实消费者接手',
          dueAt: new Date(0),
        },
      });
      const task = await db.backgroundTask.create({
        data: {
          teamId,
          ownerId: userId,
          kind,
          ...(kind === 'EXPORT'
            ? { filters: { q: item.title } }
            : { ticketId: item.id, reminderRevision: item.reminderRevision }),
        },
      });
      return { item, task };
    };
    async function harness() {
      const prefix = `worker-test-${randomUUID()}`;
      const connection = new IORedis(process.env.TEST_REDIS_URL!, {
        maxRetriesPerRequest: null,
      });
      const queue = new Queue<TaskJob>(queueName, {
        connection,
        prefix,
        defaultJobOptions: { attempts: 1 },
      });
      const events = new QueueEvents(queueName, { connection, prefix });
      const workers: ReturnType<typeof createTaskWorker>[] = [];
      const releases: (() => void)[] = [];
      await events.waitUntilReady();
      return {
        queue,
        events,
        releases,
        async worker(
          client: PrismaClient,
          options: Omit<WorkerOptions, 'connection'> = {},
        ) {
          const capture = logs();
          const worker = createTaskWorker(
            client,
            connection,
            {
              prefix,
              concurrency: 1,
              lockDuration: 10000,
              stalledInterval: 100,
              ...options,
            },
            capture.logger,
          );
          workers.push(worker);
          await worker.waitUntilReady();
          return { worker, ...capture };
        },
        async dispatch() {
          // 测试与其他套件共享数据库,只限制夹具团队;不改变生产恢复条件。
          const scoped = db.$extends({
            query: {
              backgroundTask: {
                findMany({ args, query }) {
                  args.where = { AND: [args.where ?? {}, { teamId }] };
                  return query(args);
                },
                updateMany({ args, query }) {
                  args.where = { AND: [args.where ?? {}, { teamId }] };
                  return query(args);
                },
              },
            },
          });
          await dispatchTasks(
            scoped as unknown as PrismaClient,
            queue as ReturnType<typeof createTaskQueue>,
          );
        },
        async close() {
          releases.forEach((release) => release());
          await Promise.all(workers.map((worker) => worker.close()));
          await events.close();
          await queue.obliterate({ force: true });
          await queue.close();
          connection.disconnect();
        },
      };
    }

    it.each(['EXPORT', 'REMINDER'] as const)(
      '真实 stalled 后 %s 的新执行者完成,旧执行者晚到错误不改变 E2',
      async (kind) => {
        const h = await harness();
        const { task, item } = await fixture(kind);
        const pausedA = gate(),
          resumeA = gate(),
          claimedB = gate(),
          resumeB = gate();
        h.releases.push(resumeA.open, resumeB.open);
        const oldDb = db.$extends({
          query: {
            ticket: {
              async findMany({ args, query }) {
                const result = await query(args);
                pausedA.open();
                await resumeA.promise;
                throw new Error(
                  `测试注入:旧执行者读取失败,已读取 ${result.length} 行`,
                );
              },
            },
            backgroundTask: {
              async findFirst({ args, query }) {
                await query(args);
                pausedA.open();
                await resumeA.promise;
                throw new Error('测试注入:旧提醒执行者读取失败');
              },
            },
          },
        });
        const newDb = db.$extends({
          query: {
            backgroundTask: {
              async updateMany({ args, query }) {
                const result = await query(args);
                if (args.data.status === 'RUNNING') {
                  claimedB.open();
                  await resumeB.promise;
                }
                return result;
              },
            },
          },
        });
        try {
          const a = await h.worker(oldDb as unknown as PrismaClient, {
            skipLockRenewal: true,
            skipStalledCheck: true,
            lockDuration: 1000,
          });
          const job = await h.queue.add(
            'execute',
            { taskId: task.id, run: task.run },
            { jobId: taskJobId(task.id, task.run) },
          );
          await within(pausedA.promise);
          const first = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          const b = await h.worker(newDb as unknown as PrismaClient);
          // 不删锁、不手工 emit stalled,等待 Redis 锁自然过期与真实检测器恢复。
          await b.event('task.stalled');
          await within(claimedB.promise);
          const replacement = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          expect(replacement.executionId).not.toBe(first.executionId);
          expect(replacement.executionId).not.toBeNull();
          expect(replacement).toMatchObject({
            status: 'RUNNING',
            run: task.run,
          });
          resumeA.open();
          await a.event('task.skipped');
          expect(
            await db.backgroundTask.findUnique({ where: { id: task.id } }),
          ).toEqual(replacement);
          expect(
            a.records.some((record) => record.event === 'task.attempt_failed'),
          ).toBe(false);
          const finished = job.waitUntilFinished(h.events, 8000);
          resumeB.open();
          await finished;
          expect(await job.getState()).toBe('completed');
          expect((await h.queue.getJob(job.id!))?.progress).toBe(100);
          const completed = await db.backgroundTask.findUniqueOrThrow({
            where: { id: task.id },
          });
          expect(completed).toMatchObject({
            status: 'COMPLETED',
            progress: 100,
            executionId: null,
            lastError: null,
            run: task.run,
          });
          if (kind === 'EXPORT') {
            expect(completed.resultSummary).toBe('已导出 1 条工单');
            expect(completed.resultCsv).toContain(item.title);
            expect(completed.expiresAt!.getTime()).toBeGreaterThan(Date.now());
          } else {
            expect(completed.resultSummary).toBe('已生成 1 条站内提醒');
            expect(
              await db.reminderNotification.count({
                where: { ticketId: item.id },
              }),
            ).toBe(1);
            const records = await db.ticketEvent.findMany({
              where: { ticketId: item.id, kind: 'TIMEOUT_REMINDED' },
            });
            expect(records).toHaveLength(1);
            expect(records[0].version).toBe(item.version + 1);
            expect(
              await db.ticket.findUnique({ where: { id: item.id } }),
            ).toMatchObject({ version: item.version + 1 });
          }
        } finally {
          await h.close();
        }
      },
    );

    it('真实 failed 回调迟到,恢复投递后的同 run 新租约不能被清空', async () => {
      const h = await harness();
      const { task, item } = await fixture();
      const releaseCallback = gate(),
        claimedB = gate(),
        resumeB = gate();
      h.releases.push(releaseCallback.open, resumeB.open);
      const lateWrites: Promise<unknown>[] = [];
      const oldDb = db.$extends({
        query: {
          ticket: {
            async findMany() {
              throw new Error('测试注入:导出读取失败');
            },
          },
          backgroundTask: {
            async updateMany({ args, query }) {
              if (args.data.status === 'WAITING')
                throw new Error('测试注入:失败状态暂时无法写入');
              // 对旧回调的 SQL 模拟迟到。修复后回调不再发 SQL,因此列表为空。
              if (args.data.lastError === '任务多次执行失败,请手动重试') {
                const pending = releaseCallback.promise.then(() => query(args));
                lateWrites.push(pending);
                return pending;
              }
              return query(args);
            },
          },
        },
      });
      const newDb = db.$extends({
        query: {
          backgroundTask: {
            async updateMany({ args, query }) {
              const result = await query(args);
              if (args.data.status === 'RUNNING') {
                claimedB.open();
                await resumeB.promise;
              }
              return result;
            },
          },
        },
      });
      try {
        const a = await h.worker(oldDb as unknown as PrismaClient);
        const job = await h.queue.add(
          'execute',
          { taskId: task.id, run: task.run },
          { jobId: taskJobId(task.id, task.run) },
        );
        await a.event('task.failed');
        await a.worker.pause();
        expect(await job.getState()).toBe('failed');
        expect(
          a.records.find((record) => record.event === 'task.failed'),
        ).toMatchObject({ exhausted: true });
        const first = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(first).toMatchObject({ status: 'RUNNING' });
        // 只移除本例单个失败 job,模拟队列记录丢失;不清开发 Redis。
        await job.remove();
        await db.backgroundTask.update({
          where: { id: task.id },
          data: { updatedAt: new Date(0) },
        });
        await h.dispatch();
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({ status: 'WAITING', executionId: null });
        await h.worker(newDb as unknown as PrismaClient);
        await h.dispatch();
        await within(claimedB.promise);
        const replacement = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(replacement.executionId).not.toBe(first.executionId);
        expect(replacement.executionId).not.toBeNull();
        releaseCallback.open();
        await Promise.all(lateWrites);
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toEqual(replacement);
        const newJob = (await h.queue.getJob(taskJobId(task.id, task.run)))!;
        const finished = newJob.waitUntilFinished(h.events, 8000);
        resumeB.open();
        await finished;
        expect(await newJob.getState()).toBe('completed');
        const completed = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(completed).toMatchObject({
          status: 'COMPLETED',
          progress: 100,
          executionId: null,
          lastError: null,
          run: task.run,
        });
        expect(completed.resultCsv).toContain(item.title);
        expect(completed.resultSummary).toBe('已导出 1 条工单');
        expect(completed.expiresAt!.getTime()).toBeGreaterThan(Date.now());
        expect((await h.queue.getJob(newJob.id!))?.progress).toBe(100);
      } finally {
        releaseCallback.open();
        await Promise.allSettled(lateWrites);
        await h.close();
      }
    });

    it('真实 Worker 临时失败自动重试,failed 日志不提前终止下一次执行', async () => {
      const h = await harness();
      const { task, item } = await fixture();
      let reads = 0;
      const client = db.$extends({
        query: {
          ticket: {
            async findMany({ args, query }) {
              if (++reads === 1) throw new Error('测试注入:首次读取暂时失败');
              return query(args);
            },
          },
        },
      });
      try {
        const consumer = await h.worker(client as unknown as PrismaClient);
        const job = await h.queue.add(
          'execute',
          { taskId: task.id, run: task.run },
          {
            jobId: taskJobId(task.id, task.run),
            attempts: 3,
            backoff: { type: 'exponential', delay: 30 },
          },
        );
        await job.waitUntilFinished(h.events, 8000);
        expect(
          consumer.records.filter((record) => record.event === 'task.failed'),
        ).toEqual([expect.objectContaining({ exhausted: false })]);
        expect((await h.queue.getJob(job.id!))?.attemptsMade).toBe(2);
        const completed = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(completed).toMatchObject({
          status: 'COMPLETED',
          attempts: 2,
          run: 0,
          progress: 100,
          executionId: null,
          lastError: null,
        });
        expect(completed.resultCsv).toContain(item.title);
      } finally {
        await h.close();
      }
    });

    it('真实 Worker 的永久拒绝同时保留数据库与队列 FAILED,不依赖回调代写状态', async () => {
      const h = await harness();
      const { task } = await fixture();
      await db.backgroundTask.update({
        where: { id: task.id },
        data: { filters: { q: 123 } },
      });
      try {
        const consumer = await h.worker(db);
        const job = await h.queue.add(
          'execute',
          { taskId: task.id, run: task.run },
          {
            jobId: taskJobId(task.id, task.run),
            attempts: 3,
          },
        );
        await consumer.event('task.failed');
        expect(
          consumer.records.find((record) => record.event === 'task.failed'),
        ).toMatchObject({ exhausted: true });
        expect(await job.getState()).toBe('failed');
        expect((await h.queue.getJob(job.id!))?.attemptsMade).toBe(1);
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({
          status: 'FAILED',
          attempts: 1,
          executionId: null,
          resultCsv: null,
          lastError: '导出筛选条件无效',
        });
      } finally {
        await h.close();
      }
    });

    it('stalled 超过恢复上限后,由扫描核对真实队列 FAILED,旧执行者返回也不能改写终态', async () => {
      const h = await harness();
      const { task } = await fixture();
      const pausedA = gate(),
        resumeA = gate();
      h.releases.push(resumeA.open);
      const oldDb = db.$extends({
        query: {
          ticket: {
            async findMany({ args, query }) {
              await query(args);
              pausedA.open();
              await resumeA.promise;
              throw new Error('测试注入:旧执行者晚到失败');
            },
          },
        },
      });
      try {
        const a = await h.worker(oldDb as unknown as PrismaClient, {
          skipLockRenewal: true,
          skipStalledCheck: true,
          lockDuration: 1000,
        });
        const job = await h.queue.add(
          'execute',
          { taskId: task.id, run: task.run },
          { jobId: taskJobId(task.id, task.run) },
        );
        await within(pausedA.promise);
        const b = await h.worker(db, { maxStalledCount: 0 });
        await b.event('task.failed');
        expect(await job.getState()).toBe('failed');
        expect((await h.queue.getJob(job.id!))?.failedReason).toContain(
          'stalled',
        );
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({ status: 'RUNNING' });
        await db.backgroundTask.update({
          where: { id: task.id },
          data: { updatedAt: new Date(0) },
        });
        await h.dispatch();
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toMatchObject({ status: 'WAITING', executionId: null });
        await h.dispatch();
        const terminal = await db.backgroundTask.findUniqueOrThrow({
          where: { id: task.id },
        });
        expect(terminal).toMatchObject({
          status: 'FAILED',
          executionId: null,
          resultCsv: null,
          resultSummary: null,
          expiresAt: null,
          lastError: '队列执行失败,请手动重试',
        });
        resumeA.open();
        await a.event('task.skipped');
        expect(
          await db.backgroundTask.findUnique({ where: { id: task.id } }),
        ).toEqual(terminal);
        expect(await job.getState()).toBe('failed');
      } finally {
        await h.close();
      }
    });
  },
);

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。