Files

218 lines
9.1 KiB
TypeScript
Raw Permalink Normal View History

import { describe, expect, it, jest, beforeAll, afterAll, beforeEach, afterEach } from "@jest/globals";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts";
import MongoJobsRepository from "../../src/domain/jobs/job.repository.mongo.ts";
import { CheckModel } from "../../src/domain/checks/check.model.ts";
import JobModel from "../../src/domain/jobs/job.model.ts";
import { CheckService } from "../../src/domain/checks/check.service.ts";
import { BufferService } from "../../src/service/bufferService.ts";
import { DBQueueWorker } from "../../src/worker/worker.db-queue.ts";
2026-09-20 17:37:42 -07:00
import { WorkerPipeline } from "../../src/worker/worker.pipeline.ts";
import { InMemoryMonitorsRepository } from "../helpers/InMemoryMonitorsRepository.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { Job } from "../../src/domain/jobs/job.type.ts";
import type { Check } from "../../src/domain/checks/check.type.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { ILogger } from "../../src/utils/logger.ts";
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
2026-09-17 12:30:56 -07:00
// Regression test for the dropped-check bug in the check-to-evaluate handoff.
//
2026-09-17 12:30:56 -07:00
// The evaluate step used to be driven by a timestamp cursor: the evaluator read checks with
// createdAt strictly after the monitor's lastEvaluatedAt and advanced that cursor to each
// check's createdAt. Each worker's BufferService flushes its own in-memory batch on its own
// timer, so with two processing workers a later check produced on worker B could be flushed and
// evaluated before an earlier check produced on worker A had been flushed. When A's batch landed,
// its check was older than the cursor and the evaluate query never returned it. The check existed
// in the collection but never reached the status window, stats, incidents or notifications.
//
2026-09-17 12:30:56 -07:00
// The evaluate row now carries the ids of every stored check, pushed at ingest and pulled as each
// is applied, so a late batch is always evaluated. This drives the real time-series query, the
2026-09-20 17:37:42 -07:00
// real jobs collection, the real buffer flush, the real pipeline ingest and evaluate stages and the
// real DBQueueWorker job path against an in-process mongod, so the handoff semantics under test
// are MongoDB's, not a mock's.
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
await CheckModel.createCollection(); // timeseries collections must exist before insert
await JobModel.init();
}, 120_000);
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
beforeEach(async () => {
await CheckModel.deleteMany({});
await JobModel.deleteMany({});
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const MONITOR_ID = new mongoose.Types.ObjectId().toString();
const TEAM_ID = new mongoose.Types.ObjectId().toString();
const T0 = Date.parse("2026-01-01T00:00:00.000Z");
const INTERVAL_MS = 30_000;
const makeMonitor = (): Monitor =>
({
id: MONITOR_ID,
teamId: TEAM_ID,
type: "http",
interval: INTERVAL_MS,
status: "up",
isActive: true,
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
recentChecks: [],
lastEvaluatedAt: 0,
}) as unknown as Monitor;
const makeStatusResponse = (): MonitorStatusResponse => ({
monitorId: MONITOR_ID,
teamId: TEAM_ID,
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
});
// Production stamps createdAt at the moment the probe ran, on the producing worker; pin it so
// the two workers' checks have a known order regardless of wall clock.
const producedAt = (check: Check, at: number): Check => {
const createdAt = new Date(at).toISOString();
return { ...check, createdAt, updatedAt: createdAt };
};
const stubSettingsService = { getSettings: () => ({ nodeEnv: "production" }) };
const stubGeoChecksService = { createGeoChecks: jest.fn<any>().mockResolvedValue([]) };
const stubDockerLogsService = { createDockerLogs: jest.fn<any>().mockResolvedValue(0) };
describe("Evaluate cursor with more than one processing worker", () => {
let logger: ILogger;
let monitorsRepository: InMemoryMonitorsRepository;
let checksRepository: MongoChecksRepository;
let checkService: CheckService;
let bufferA: BufferService;
let bufferB: BufferService;
let evaluatedCheckIds: string[];
let evaluator: DBQueueWorker;
let evaluatorJobsRepository: MongoJobsRepository;
2026-09-20 17:37:42 -07:00
// One pipeline and BufferService per worker process, each flushing into the same collection and
// jobs table. Only stages 2 and 3 are exercised: the status service is stubbed to record which
// checks reached evaluation, and stage 1's network dependencies are never called.
const createWorkerPipeline = (workerId: string) => {
const jobsRepository = new MongoJobsRepository(workerId);
const bufferService = new BufferService(
logger,
stubGeoChecksService as any,
stubDockerLogsService as any,
stubSettingsService as any,
2026-09-20 17:37:42 -07:00
(checks): Promise<void> => pipeline.ingestChecks(checks)
);
2026-09-20 17:37:42 -07:00
const pipeline = new WorkerPipeline({
logger,
monitorsRepository: monitorsRepository as any,
maintenanceWindowsRepository: { findByMonitorId: jest.fn<any>().mockResolvedValue([]) } as any,
checksRepository,
jobsRepository,
checkService,
2026-09-25 11:39:16 -07:00
providerRegistry: { probe: jest.fn<any>() } as any,
2026-09-20 17:37:42 -07:00
proxyResolver: { resolve: jest.fn<any>() } as any,
bufferService,
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
statusService: {
2026-09-23 13:38:02 -07:00
updateMonitorStatus: async (check: Check, monitor: Monitor) => {
2026-09-20 17:37:42 -07:00
evaluatedCheckIds.push(check.id);
2026-09-24 14:03:34 -07:00
return { monitor, statusChanged: false, prevStatus: monitor.status };
2026-09-20 17:37:42 -07:00
},
} as any,
dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) } as any,
});
return { pipeline, bufferService, jobsRepository };
};
// Claim every due evaluate job and run it through the worker's real per-job path.
const runDueEvaluateJobs = async () => {
const jobs = await evaluatorJobsRepository.claimDueBatch("evaluate", 20, Date.now());
const runJob = (evaluator as unknown as { runJob: (job: Job) => Promise<void> }).runJob;
for (const job of jobs) await runJob(job);
return jobs.length;
};
beforeEach(() => {
logger = createMockLogger() as unknown as ILogger;
monitorsRepository = new InMemoryMonitorsRepository();
monitorsRepository.seed(makeMonitor());
checksRepository = new MongoChecksRepository(logger);
checkService = new CheckService(monitorsRepository, logger, checksRepository);
evaluatedCheckIds = [];
2026-09-20 17:37:42 -07:00
const workerA = createWorkerPipeline("worker-a");
const workerB = createWorkerPipeline("worker-b");
bufferA = workerA.bufferService;
bufferB = workerB.bufferService;
const unused = jest.fn<any>().mockResolvedValue(undefined);
evaluatorJobsRepository = workerB.jobsRepository;
evaluator = new DBQueueWorker({
logger,
isDbConnected: () => true,
jobsRepository: evaluatorJobsRepository,
monitorsRepository,
bufferService: bufferB,
2026-09-20 17:37:42 -07:00
handlers: {
check: unused,
evaluate: workerB.pipeline.handleEvaluate,
"geo-check": unused,
"cleanup-orphaned": unused,
"cleanup-retention": unused,
egress: unused,
},
queueWorkersRepository: {} as any,
queueMode: "worker",
queuePrimaryProcesses: true,
workerId: "worker-b",
});
});
afterEach(async () => {
await bufferA.shutdown();
await bufferB.shutdown();
});
it("evaluates every persisted check even when a later worker's batch flushes first", async () => {
// t=0: worker A holds the check job, probes, and buffers the result. Its flush timer has not fired.
const c1 = producedAt(checkService.toCheck(makeStatusResponse())!, T0);
bufferA.addToBuffer(c1);
// t=30s: worker B claims the next run of the same job, probes, and buffers the result.
const c2 = producedAt(checkService.toCheck(makeStatusResponse())!, T0 + INTERVAL_MS);
bufferB.addToBuffer(c2);
// t=45s: worker B's flush timer fires first. c2 is inserted and an evaluate job is queued.
await bufferB.flushBuffer();
expect(await runDueEvaluateJobs()).toBe(1);
expect(evaluatedCheckIds).toEqual([c2.id]);
// t=60s: worker A's flush timer fires. c1 is inserted and the evaluate job is pulled due again.
await bufferA.flushBuffer();
expect(await runDueEvaluateJobs()).toBe(1);
// Both checks are durably stored and visible in history.
const stored = await CheckModel.find({ "metadata.monitorId": new mongoose.Types.ObjectId(MONITOR_ID) }).lean();
expect(stored.map((doc) => String(doc._id)).sort()).toEqual([c1.id, c2.id].sort());
// Every stored check must reach the evaluator exactly once.
expect(evaluatedCheckIds).toHaveLength(2);
expect(evaluatedCheckIds).toContain(c1.id);
});
});