TTeamDesk 学习手册

tests/worker-process.integration.test.ts

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

ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { fork, spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { createServer, connect, type Socket } from 'node:net';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
import { heartbeatRegistryKey, hasHeartbeat } from '@/server/jobs/heartbeat';
import { queueName, taskJobId, type TaskJob } from '@/server/jobs/queue';

type ProcessInstance = {
  child: ChildProcess;
  scope: string;
  records: { event: string; taskId?: string; instanceId?: string }[];
  exited: Promise<{ code: number | null; signal: string | null }>;
  event(name: string): Promise<void>;
  resume(label: string): void;
  stop(): Promise<{ code: number | null; signal: string | null }>;
};

async function within<T>(promise: Promise<T>, ms = 10000): Promise<T> {
  let timer!: ReturnType<typeof setTimeout>;
  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error('进程集成测试等待超时')), ms);
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}
async function until(check: () => Promise<boolean>, ms = 10000) {
  const deadline = Date.now() + ms;
  while (!(await check())) {
    if (Date.now() >= deadline) throw new Error('进程状态条件未满足');
    // 这里只轮询可观察状态;谁先执行由 IPC、日志和数据库锁确定。
    await new Promise((resolve) => setTimeout(resolve, 20));
  }
}

describe.skipIf(!process.env.TEST_DATABASE_URL || !process.env.TEST_REDIS_URL)(
  '真实进程的 Worker 心跳、SIGTERM 与恢复',
  () => {
    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: '进程测试', email: `${userId}@example.com` },
      });
      teamId = (
        await db.team.create({
          data: {
            name: '进程隔离团队',
            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: `Process-${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 = `process-test-${randomUUID()}`;
      const redis = new IORedis(process.env.TEST_REDIS_URL!, {
        maxRetriesPerRequest: null,
      });
      const queue = new Queue<TaskJob>(queueName, {
        connection: redis,
        prefix,
      });
      const children: ProcessInstance[] = [];
      const baseEnv = {
        ...process.env,
        DATABASE_URL: process.env.TEST_DATABASE_URL!,
        REDIS_URL: process.env.TEST_REDIS_URL!,
        QUEUE_PREFIX: prefix,
        LOG_LEVEL: 'info',
      };
      function start(
        config: Record<string, unknown> = {},
        scope = randomUUID(),
      ): ProcessInstance {
        const child = fork('tests/fixtures/worker-process.ts', [], {
          execArgv: ['--import', 'tsx'],
          silent: true,
          env: {
            ...baseEnv,
            WORKER_INSTANCE_ID: scope,
            WORKER_PROCESS_FIXTURE: JSON.stringify({ teamId, ...config }),
          },
        });
        const bus = new EventEmitter();
        const checkpoints: string[] = [];
        const records: {
          event: string;
          taskId?: string;
          instanceId?: string;
        }[] = [];
        let output = '',
          stderr = '';
        child.stdout!.on('data', (data) => {
          output += String(data);
          let end;
          while ((end = output.indexOf('\n')) >= 0) {
            const line = output.slice(0, end);
            output = output.slice(end + 1);
            try {
              const record = JSON.parse(line);
              records.push(record);
              bus.emit(record.event);
            } catch {
              /* 忽略非 JSON 工具输出。 */
            }
          }
        });
        child.stderr!.on('data', (data) => {
          stderr += String(data).slice(0, 2000);
        });
        child.on('message', (message) => {
          const label = (message as { checkpoint?: string }).checkpoint;
          if (label) {
            checkpoints.push(label);
            bus.emit(`checkpoint:${label}`);
          }
        });
        const exited = new Promise<{
          code: number | null;
          signal: string | null;
        }>((resolve, reject) => {
          child.once('error', reject);
          child.once('close', (code, signal) => resolve({ code, signal }));
        });
        const result = {
          child,
          scope,
          records,
          exited,
          async event(name: string) {
            if (
              records.some((record) => record.event === name) ||
              (name.startsWith('checkpoint:') &&
                checkpoints.includes(name.slice(11)))
            )
              return;
            await within(
              Promise.race([
                new Promise<void>((resolve) => bus.once(name, resolve)),
                exited.then((state) => {
                  throw new Error(
                    `等待 ${name} 时进程已退出:${state.code}/${state.signal} ${stderr}`,
                  );
                }),
              ]),
            );
          },
          resume(label: string) {
            if (child.connected) child.send({ resume: label });
          },
          async stop() {
            if (child.exitCode === null && child.signalCode === null)
              child.kill('SIGTERM');
            return within(exited);
          },
        };
        children.push(result);
        return result;
      }
      return {
        prefix,
        redis,
        queue,
        start,
        healthy: (scope?: string) => hasHeartbeat(redis, scope, prefix),
        async probe(scope: string, redisUrl = process.env.TEST_REDIS_URL!) {
          const child = spawn(
            process.execPath,
            ['--import', 'tsx', 'scripts/worker-health.ts'],
            {
              env: {
                ...baseEnv,
                REDIS_URL: redisUrl,
                WORKER_INSTANCE_ID: scope,
              },
              stdio: 'ignore',
            },
          );
          try {
            return await within(
              new Promise<number | null>((resolve, reject) => {
                child.once('error', reject);
                child.once('close', resolve);
              }),
              7000,
            );
          } finally {
            if (child.exitCode === null && child.signalCode === null)
              child.kill('SIGKILL');
          }
        },
        async close() {
          for (const current of children) {
            if (
              current.child.exitCode === null &&
              current.child.signalCode === null
            )
              current.child.kill('SIGKILL');
          }
          await Promise.all(children.map((current) => current.exited));
          await queue.obliterate({ force: true });
          for (const key of await redis.keys(`${prefix}:worker-heartbeat:*`))
            await redis.del(key);
          await queue.close();
          redis.disconnect();
        },
      };
    }
    async function completed(id: string) {
      await until(
        async () =>
          (await db.backgroundTask.findUnique({ where: { id } }))?.status ===
          'COMPLETED',
      );
      return db.backgroundTask.findUniqueOrThrow({ where: { id } });
    }
    const checkResult = async (
      kind: 'EXPORT' | 'REMINDER',
      data: Awaited<ReturnType<typeof fixture>>,
    ) => {
      const result = await completed(data.task.id);
      expect(result).toMatchObject({
        status: 'COMPLETED',
        progress: 100,
        executionId: null,
        lastError: null,
        run: data.task.run,
      });
      if (kind === 'EXPORT') {
        expect(result.resultCsv).toContain(data.item.title);
        expect(result.resultSummary).toBe('已导出 1 条工单');
        expect(result.expiresAt!.getTime()).toBeGreaterThan(Date.now());
      } else {
        expect(result.resultSummary).toBe('已生成 1 条站内提醒');
        expect(
          await db.reminderNotification.count({
            where: { ticketId: data.item.id },
          }),
        ).toBe(1);
        const events = await db.ticketEvent.findMany({
          where: { ticketId: data.item.id, kind: 'TIMEOUT_REMINDED' },
        });
        expect(events).toHaveLength(1);
        expect(events[0].version).toBe(data.item.version + 1);
        expect(
          await db.ticket.findUnique({ where: { id: data.item.id } }),
        ).toMatchObject({ version: data.item.version + 1 });
      }
      return result;
    };

    it.each(['EXPORT', 'REMINDER'] as const)(
      'SIGTERM 排空在途 %s、持续续锁,不再认领新任务',
      async (kind) => {
        const h = await harness();
        const data = await fixture(kind);
        // 提前准备待投递夹具,避免提醒事务持有团队锁时,新建行的外键检查等待该锁。
        const waiting = await fixture();
        await db.backgroundTask.update({
          where: { id: waiting.task.id },
          data: { status: 'FAILED' },
        });
        try {
          const a = h.start({
            pause: 'read',
            taskId: data.task.id,
            ticketId: data.item.id,
            heartbeatTtlMs: 5000,
          });
          await a.event('checkpoint:read');
          await until(() => h.healthy(a.scope));
          expect(await h.probe(a.scope)).toBe(0);
          const leased = await db.backgroundTask.findUniqueOrThrow({
            where: { id: data.task.id },
          });
          expect(leased.executionId).not.toBeNull();
          a.child.kill('SIGTERM');
          await a.event('worker.stopping');
          await until(async () => !(await h.healthy(a.scope)), 2000);
          expect(await h.probe(a.scope)).toBe(1);
          a.child.kill('SIGTERM');
          await db.backgroundTask.update({
            where: { id: waiting.task.id },
            data: { status: 'WAITING' },
          });
          const deadline = Date.now() + 1100;
          await until(
            async () =>
              Date.now() > deadline &&
              (await h.redis.pttl(
                `${h.queue.toKey(taskJobId(data.task.id, data.task.run))}:lock`,
              )) > 0,
          );
          expect(
            await db.backgroundTask.findUnique({ where: { id: data.task.id } }),
          ).toEqual(leased);
          a.resume('read');
          expect(await within(a.exited)).toEqual({ code: 0, signal: null });
          expect(
            a.records.filter((record) => record.event === 'worker.stopping'),
          ).toHaveLength(1);
          const original = await checkResult(kind, data);
          expect(
            await db.backgroundTask.findUnique({
              where: { id: waiting.task.id },
            }),
          ).toMatchObject({ status: 'WAITING', executionId: null });
          const b = h.start();
          await completed(waiting.task.id);
          expect(await b.stop()).toEqual({ code: 0, signal: null });
          expect(
            await db.backgroundTask.findUnique({ where: { id: data.task.id } }),
          ).toEqual(original);
          expect(await h.healthy()).toBe(false);
        } finally {
          await h.close();
        }
      },
      30000,
    );

    it.each(['EXPORT', 'REMINDER'] as const)(
      'SIGTERM 超时退出后 %s 保留未完成租约,新进程 stalled 接手并只提交一次',
      async (kind) => {
        const h = await harness();
        const data = await fixture(kind);
        try {
          const a = h.start({
            pause: 'read',
            taskId: data.task.id,
            ticketId: data.item.id,
            shutdownTimeoutMs: 1000,
          });
          await a.event('checkpoint:read');
          const first = await db.backgroundTask.findUniqueOrThrow({
            where: { id: data.task.id },
          });
          a.child.kill('SIGTERM');
          expect(await within(a.exited)).toEqual({ code: 1, signal: null });
          expect(
            a.records.some(
              (record) => record.event === 'worker.shutdown_timeout',
            ),
          ).toBe(true);
          expect(
            await db.backgroundTask.findUnique({ where: { id: data.task.id } }),
          ).toEqual(first);
          expect(
            await db.reminderNotification.count({
              where: { ticketId: data.item.id },
            }),
          ).toBe(0);
          expect(
            await db.ticket.findUnique({ where: { id: data.item.id } }),
          ).toMatchObject({ version: data.item.version });
          expect(await h.healthy()).toBe(false);
          const b = h.start({ pause: 'claim' });
          await b.event('task.stalled');
          await b.event('checkpoint:claim');
          const replacement = await db.backgroundTask.findUniqueOrThrow({
            where: { id: data.task.id },
          });
          expect(replacement).toMatchObject({
            status: 'RUNNING',
            run: first.run,
          });
          expect(replacement.executionId).not.toBeNull();
          expect(replacement.executionId).not.toBe(first.executionId);
          b.resume('claim');
          await checkResult(kind, data);
          await until(
            async () =>
              (await h.queue.getJob(taskJobId(data.task.id, data.task.run)))
                ?.progress === 100,
          );
          expect(
            await (await h.queue.getJob(
              taskJobId(data.task.id, data.task.run),
            ))!.getState(),
          ).toBe('completed');
          expect(await b.stop()).toEqual({ code: 0, signal: null });
        } finally {
          await h.close();
        }
      },
      30000,
    );

    it('同作用域两个进程退出一个,不删除同伴心跳;最后退出立即不健康', async () => {
      const h = await harness();
      const scope = randomUUID();
      try {
        const a = h.start({}, scope),
          b = h.start({ heartbeatTtlMs: 5000 }, scope);
        await until(
          async () =>
            (await h.redis.zcard(heartbeatRegistryKey(scope, h.prefix))) === 2,
        );
        expect(await h.probe(scope)).toBe(0);
        // 冻结续期,防止错误删键后恰好被同伴的新心跳掩盖。
        b.child.kill('SIGSTOP');
        expect(await a.stop()).toEqual({ code: 0, signal: null });
        expect(await h.healthy(scope)).toBe(true);
        expect(await h.redis.zcard(heartbeatRegistryKey(scope, h.prefix))).toBe(
          1,
        );
        b.child.kill('SIGCONT');
        expect(await h.probe(scope)).toBe(0);
        expect(await b.stop()).toEqual({ code: 0, signal: null });
        expect(await h.healthy()).toBe(false);
        expect(await h.probe(scope)).toBe(1);
      } finally {
        await h.close();
      }
    }, 30000);

    it('冻结一个进程后其作用域探针过期失败,其他进程仍维持全局健康', async () => {
      const h = await harness();
      try {
        const a = h.start(),
          b = h.start();
        await until(
          async () => (await h.healthy(a.scope)) && (await h.healthy(b.scope)),
        );
        a.child.kill('SIGSTOP');
        await until(async () => !(await h.healthy(a.scope)));
        expect(await h.healthy()).toBe(true);
        expect(await h.probe(a.scope)).toBe(1);
        expect(await h.probe(b.scope)).toBe(0);
        a.child.kill('SIGCONT');
        expect(await a.stop()).toEqual({ code: 0, signal: null });
        expect(await h.healthy(b.scope)).toBe(true);
        expect(await b.stop()).toEqual({ code: 0, signal: null });
      } finally {
        await h.close();
      }
    }, 30000);

    it('SIGTERM 等待在途扫描,扫描返回后不复活心跳,新进程接手它留下的排队任务', async () => {
      const h = await harness();
      try {
        const a = h.start({ pauseDispatch: true, heartbeatTtlMs: 5000 });
        await a.event('checkpoint:dispatch');
        expect(await h.healthy(a.scope)).toBe(true);
        a.child.kill('SIGTERM');
        await a.event('worker.stopping');
        await until(async () => !(await h.healthy(a.scope)), 2000);
        const data = await fixture();
        a.resume('dispatch');
        expect(await within(a.exited)).toEqual({ code: 0, signal: null });
        expect(await h.healthy()).toBe(false);
        expect(
          await db.backgroundTask.findUnique({ where: { id: data.task.id } }),
        ).toMatchObject({ status: 'WAITING', executionId: null });
        const b = h.start();
        await checkResult('EXPORT', data);
        expect(await b.stop()).toEqual({ code: 0, signal: null });
      } finally {
        await h.close();
      }
    }, 30000);

    it('真实 Redis 连接成功后读取无响应,健康探针仍在总时限内失败退出', async () => {
      const h = await harness();
      const sockets = new Set<Socket>();
      let reached!: () => void;
      const blocked = new Promise<void>((resolve) => {
        reached = resolve;
      });
      // 仅代理本次探针连接:转发握手,完整解析 RESP 命令后截住 EVAL 的响应。
      const target = new URL(process.env.TEST_REDIS_URL!);
      const server = createServer((client) => {
        const upstream = connect(Number(target.port || 6379), target.hostname);
        sockets.add(client);
        sockets.add(upstream);
        upstream.on('error', () => client.destroy());
        client.on('error', () => upstream.destroy());
        upstream.on('data', (data) => client.write(data));
        let buffered = Buffer.alloc(0);
        client.on('data', (data) => {
          buffered = Buffer.concat([buffered, data]);
          while (buffered.length) {
            const line = buffered.indexOf('\r\n');
            if (line < 0) return;
            const count = Number(buffered.subarray(1, line).toString());
            let cursor = line + 2,
              command = '';
            let complete = true;
            for (let i = 0; i < count; i++) {
              const end = buffered.indexOf('\r\n', cursor);
              if (end < 0) {
                complete = false;
                break;
              }
              const size = Number(
                buffered.subarray(cursor + 1, end).toString(),
              );
              if (buffered.length < end + 2 + size + 2) {
                complete = false;
                break;
              }
              if (i === 0)
                command = buffered
                  .subarray(end + 2, end + 2 + size)
                  .toString()
                  .toLowerCase();
              cursor = end + 2 + size + 2;
            }
            if (!complete) return;
            const raw = buffered.subarray(0, cursor);
            buffered = buffered.subarray(cursor);
            if (command === 'eval') reached();
            else upstream.write(raw);
          }
        });
      });
      try {
        await new Promise<void>((resolve) =>
          server.listen(0, '127.0.0.1', resolve),
        );
        const proxy = new URL(target);
        proxy.hostname = '127.0.0.1';
        proxy.port = String((server.address() as { port: number }).port);
        const probing = h.probe(randomUUID(), proxy.toString());
        await within(blocked);
        const readingSince = Date.now();
        expect(await probing).toBe(1);
        // 证明是共享健康查询的 2 秒读取上限,而非短脚本外层 4 秒兜底。
        expect(Date.now() - readingSince).toBeLessThan(3000);
      } finally {
        sockets.forEach((socket) => socket.destroy());
        await new Promise<void>((resolve) => server.close(() => resolve()));
        await h.close();
      }
    }, 30000);
  },
);

搜索教程

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

阅读设置

外观
正文字号

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

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