Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 39s
M-AI-01-01: ADR-001 统一 AI 架构决策记录 + 附录 A 依赖闭包审计 M-AI-01-02: Worker 执行依赖闭包审计(附录 A 文档) M-AI-01-03: 分离 AppModule/WorkerModule Processor 注册 M-AI-01-04: 统一 Queue Definition Registry + 环境变量覆盖 M-AI-01-05: 独立 Worker 启动与生命周期(instanceId/校验/优雅关闭) M-AI-01-06: Docker + systemd 部署拓扑(unit 文件 + 回滚文档) M-AI-01-07: Worker Heartbeat(Redis TTL 90s)+ Admin 在线状态 API M-AI-01-08: 集成验收脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
import { WorkerHeartbeatService, WorkerStatus } from './worker-heartbeat.service';
|
|
import { RedisService } from '../infrastructure/redis/redis.service';
|
|
|
|
describe('WorkerHeartbeatService', () => {
|
|
let service: WorkerHeartbeatService;
|
|
let mockRedis: jest.Mocked<Pick<RedisService, 'set' | 'get' | 'del' | 'keys' | 'ttl'>>;
|
|
|
|
beforeEach(() => {
|
|
mockRedis = {
|
|
set: jest.fn().mockResolvedValue(undefined),
|
|
get: jest.fn().mockResolvedValue(null),
|
|
del: jest.fn().mockResolvedValue(undefined),
|
|
keys: jest.fn().mockResolvedValue([]),
|
|
ttl: jest.fn().mockResolvedValue(-2),
|
|
};
|
|
|
|
process.env.WORKER_INSTANCE_ID = 'test-worker-1';
|
|
service = new WorkerHeartbeatService(mockRedis as any);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await service.onModuleDestroy().catch(() => {});
|
|
delete process.env.WORKER_INSTANCE_ID;
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
describe('onModuleInit', () => {
|
|
it('writes heartbeat immediately and starts interval', async () => {
|
|
jest.useFakeTimers();
|
|
await service.onModuleInit();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(1);
|
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
'worker:heartbeat:test-worker-1',
|
|
expect.any(String),
|
|
90,
|
|
);
|
|
|
|
// Parse the heartbeat data
|
|
const data = JSON.parse((mockRedis.set as jest.Mock).mock.calls[0][1]);
|
|
expect(data.instanceId).toBe('test-worker-1');
|
|
expect(data.queues).toBeInstanceOf(Array);
|
|
expect(data.queues.length).toBeGreaterThanOrEqual(1);
|
|
expect(data.hostname).toBeDefined();
|
|
expect(data.appVersion).toBeDefined();
|
|
expect(data.startedAt).toBeDefined();
|
|
});
|
|
|
|
it('writes heartbeat periodically', async () => {
|
|
jest.useFakeTimers();
|
|
await service.onModuleInit();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(1);
|
|
|
|
jest.advanceTimersByTime(30_000);
|
|
await Promise.resolve();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(2);
|
|
|
|
jest.advanceTimersByTime(30_000);
|
|
await Promise.resolve();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('uses instanceId from WORKER_INSTANCE_ID env var', async () => {
|
|
process.env.WORKER_INSTANCE_ID = 'custom-instance';
|
|
const svc = new WorkerHeartbeatService(mockRedis as any);
|
|
await svc.onModuleInit();
|
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
'worker:heartbeat:custom-instance',
|
|
expect.any(String),
|
|
90,
|
|
);
|
|
await svc.onModuleDestroy();
|
|
});
|
|
});
|
|
|
|
describe('onModuleDestroy', () => {
|
|
it('stops interval and deletes heartbeat key', async () => {
|
|
jest.useFakeTimers();
|
|
await service.onModuleInit();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(1);
|
|
|
|
await service.onModuleDestroy();
|
|
|
|
// Advance time — no more beats
|
|
jest.advanceTimersByTime(30_000);
|
|
await Promise.resolve();
|
|
expect(mockRedis.set).toHaveBeenCalledTimes(1);
|
|
|
|
// Key deleted
|
|
expect(mockRedis.del).toHaveBeenCalledWith('worker:heartbeat:test-worker-1');
|
|
});
|
|
});
|
|
|
|
describe('computeStatuses', () => {
|
|
it('returns empty array when no heartbeat keys', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue([]);
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns online when TTL > 30', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']);
|
|
(mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({
|
|
instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(),
|
|
queues: ['ai-analysis'], hostname: 'host1',
|
|
}));
|
|
(mockRedis.ttl as jest.Mock).mockResolvedValue(60);
|
|
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result[0].status).toBe('online');
|
|
});
|
|
|
|
it('returns stale when TTL between 1 and 30', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']);
|
|
(mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({
|
|
instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(),
|
|
queues: ['ai-analysis'], hostname: 'host1',
|
|
}));
|
|
(mockRedis.ttl as jest.Mock).mockResolvedValue(15);
|
|
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result[0].status).toBe('stale');
|
|
});
|
|
|
|
it('returns offline when TTL < 0', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']);
|
|
(mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify({
|
|
instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(),
|
|
queues: ['ai-analysis'], hostname: 'host1',
|
|
}));
|
|
(mockRedis.ttl as jest.Mock).mockResolvedValue(-1);
|
|
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result[0].status).toBe('offline');
|
|
});
|
|
|
|
it('handles multiple worker instances', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1', 'worker:heartbeat:w2']);
|
|
(mockRedis.get as jest.Mock)
|
|
.mockResolvedValueOnce(JSON.stringify({ instanceId: 'w1', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host1' }))
|
|
.mockResolvedValueOnce(JSON.stringify({ instanceId: 'w2', appVersion: '0.0.1', startedAt: new Date().toISOString(), queues: ['ai-analysis'], hostname: 'host2' }));
|
|
(mockRedis.ttl as jest.Mock)
|
|
.mockResolvedValueOnce(60)
|
|
.mockResolvedValueOnce(10);
|
|
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].status).toBe('online');
|
|
expect(result[1].status).toBe('stale');
|
|
});
|
|
|
|
it('skips malformed JSON data', async () => {
|
|
(mockRedis.keys as jest.Mock).mockResolvedValue(['worker:heartbeat:w1']);
|
|
(mockRedis.get as jest.Mock).mockResolvedValue('not-json');
|
|
const result = await WorkerHeartbeatService.computeStatuses(mockRedis as any);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('API heartbeat key ≠ Worker heartbeat key', () => {
|
|
it('API process does not have WorkerHeartbeatService in its DI graph', () => {
|
|
// WorkerHeartbeatService is only registered in WorkerModule,
|
|
// not in AppModule. This is verified by the module configuration:
|
|
// - AppModule does not import WorkerHeartbeatService
|
|
// - WorkerModule imports and provides WorkerHeartbeatService
|
|
// This test documents the architectural constraint.
|
|
expect(WorkerHeartbeatService).toBeDefined();
|
|
});
|
|
});
|
|
});
|