rearm jobs on maintenance window pause/delete

This commit is contained in:
Alex Holliday committed 2026-06-18 10:17:33 -07:00
1 parent 6aa9360b98
commit a54fdd89ab
6 files changed
+80 -3

No files matched your search

+1
View File
@@ -338,6 +338,7 @@ export const initializeServices = async ({
const maintenanceWindowService = new MaintenanceWindowService({
monitorsRepository,
maintenanceWindowsRepository,
jobsRepository,
});
const monitorService = new MonitorService({
worker,
@@ -52,6 +52,9 @@ export interface IJobsRepository {
// Edit job schedule
updateScheduleById(refId: string, type: JobType, intervalMs: number | null): Promise<boolean>;
// Re-arm the check/geo-check rows. Add jitter to prevent herd
markMonitorsDue(monitorIds: string[], now: number): Promise<number>;
// Delete, drop all rows
deleteById(refId: string): Promise<boolean>;
@@ -2,6 +2,9 @@ import { IJobsRepository, JobPageQuery } from "@/domain/jobs/job.repository.inte
import JobModel, { JobDocument } from "@/domain/jobs/job.model.js";
import { JobType, Job, JobSeed, BACKOFF_MS, LOCK_MS, PARKED, jobId } from "@/domain/jobs/job.type.js";
// Cap for rearm interval, never want to wait more than 15s.
const REARM_JITTER_MAX_MS = 15_000;
class MongoJobsRepository implements IJobsRepository {
constructor(private readonly workerId: string) {}
@@ -249,6 +252,21 @@ class MongoJobsRepository implements IJobsRepository {
return res.modifiedCount === 1;
};
markMonitorsDue = async (monitorIds: string[], now: number) => {
if (monitorIds.length === 0) return 0;
const res = await JobModel.updateMany({ refId: { $in: monitorIds }, type: { $in: ["check", "geo-check"] } }, [
{
// Spread rearmed jobs, but never for more than rearm jitter max
$set: {
nextScheduledAt: {
$add: [now, { $floor: { $multiply: [{ $rand: {} }, { $min: ["$intervalMs", REARM_JITTER_MAX_MS] }] } }],
},
},
},
]);
return res.modifiedCount;
};
deleteById = async (refId: string) => {
const res = await JobModel.deleteMany({ refId }); // Delete all jobs for monitor
return res.deletedCount > 0;
@@ -1,5 +1,6 @@
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import type { DurationUnit, MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js";
import { AppError } from "@/utils/AppError.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
@@ -40,16 +41,20 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
private jobsRepository: IJobsRepository;
constructor({
monitorsRepository,
maintenanceWindowsRepository,
jobsRepository,
}: {
monitorsRepository: IMonitorsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
jobsRepository: IJobsRepository;
}) {
this.monitorsRepository = monitorsRepository;
this.maintenanceWindowsRepository = maintenanceWindowsRepository;
this.jobsRepository = jobsRepository;
}
get serviceName() {
@@ -71,6 +76,8 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId));
if (toInitializing.length > 0) {
await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }, ["paused"]);
// Rearm jobs to run soon (now + jitter to avoid herding)
await this.jobsRepository.markMonitorsDue(toInitializing, now.getTime());
}
};
@@ -396,6 +396,44 @@ describe("MongoJobsRepository", () => {
expect((await readRow("geo:mon-1"))?.intervalMs).toBe(60_000); // untouched
});
it("markMonitorsDue re-arms check and geo rows, capping the jitter for long intervals", async () => {
const armAt = NOW + 5_000_000; // well past the seeded nextScheduledAt so the jittered value is unambiguous
await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", intervalMs: 10_000, nextScheduledAt: NOW }); // shorter than the cap
await seedJob({ _id: "geo:mon-1", refId: "mon-1", type: "geo-check", intervalMs: 3_600_000, nextScheduledAt: NOW }); // far longer than the cap
const modified = await repo.markMonitorsDue(["mon-1"], armAt);
expect(modified).toBe(2);
const check = await readRow("check:mon-1");
const geo = await readRow("geo:mon-1");
// Short interval spreads over its own interval; long interval is capped so it still runs soon
expect(check!.nextScheduledAt).toBeGreaterThanOrEqual(armAt);
expect(check!.nextScheduledAt).toBeLessThan(armAt + 10_000);
expect(geo!.nextScheduledAt).toBeGreaterThanOrEqual(armAt);
expect(geo!.nextScheduledAt).toBeLessThan(armAt + 15_000); // REARM_JITTER_MS, not the hour-long interval
});
it("markMonitorsDue only touches the listed monitors and ignores evaluate/cleanup rows", async () => {
await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", nextScheduledAt: NOW });
await seedJob({ _id: "check:mon-2", refId: "mon-2", type: "check", nextScheduledAt: NOW });
await seedJob({ _id: "evaluate:mon-1", refId: "mon-1", type: "evaluate", nextScheduledAt: NOW });
const modified = await repo.markMonitorsDue(["mon-1"], NOW + 1_000);
expect(modified).toBe(1); // only check:mon-1
expect((await readRow("check:mon-2"))?.nextScheduledAt).toBe(NOW); // other monitor untouched
expect((await readRow("evaluate:mon-1"))?.nextScheduledAt).toBe(NOW); // evaluate row untouched
});
it("markMonitorsDue is a no-op for an empty monitor list", async () => {
await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", nextScheduledAt: NOW });
const modified = await repo.markMonitorsDue([], NOW + 1_000);
expect(modified).toBe(0);
expect((await readRow("check:mon-1"))?.nextScheduledAt).toBe(NOW);
});
it("deleteById drops every row for the monitor", async () => {
await seedJob({ _id: "check:mon-1", type: "check" });
await seedJob({ _id: "geo:mon-1", type: "geo-check" });
@@ -2,6 +2,7 @@ import { describe, expect, it, jest } from "@jest/globals";
import { MaintenanceWindowService } from "../../../src/domain/maintenance-windows/maintenance-window.service.ts";
import type { IMaintenanceWindowsRepository } from "../../../src/domain/maintenance-windows/maintenance-window.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts";
import type { MaintenanceWindow } from "../../../src/domain/maintenance-windows/maintenance-window.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -27,14 +28,21 @@ const createMonitorsRepo = () =>
updateByIds: jest.fn().mockResolvedValue(0),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createJobsRepo = () =>
({
markMonitorsDue: jest.fn().mockResolvedValue(0),
}) as unknown as jest.Mocked<IJobsRepository>;
const createService = (overrides?: {
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
maintenanceWindowsRepository?: ReturnType<typeof createMaintenanceWindowsRepo>;
jobsRepository?: ReturnType<typeof createJobsRepo>;
}) => {
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const maintenanceWindowsRepository = overrides?.maintenanceWindowsRepository ?? createMaintenanceWindowsRepo();
const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository });
return { service, monitorsRepository, maintenanceWindowsRepository };
const jobsRepository = overrides?.jobsRepository ?? createJobsRepo();
const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository, jobsRepository });
return { service, monitorsRepository, maintenanceWindowsRepository, jobsRepository };
};
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => ({
@@ -267,12 +275,14 @@ describe("MaintenanceWindowService", () => {
it("flips covered monitors to initializing when deleting an active window", async () => {
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
const { service, monitorsRepository, jobsRepository } = createService({ maintenanceWindowsRepository });
await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]);
// Monitors leaving maintenance are re-armed to run soon instead of waiting out the interval
expect(jobsRepository.markMonitorsDue).toHaveBeenCalledWith(["mon-1", "mon-2"], expect.any(Number));
});
it("excludes the deleted window from the overlap check", async () => {