Merge branch 'develop' into feat/customcss-status-page

This commit is contained in:
Arnaud GRIMBERT authored and GitHub committed 2026-06-24 20:27:57 +02:00
commit 969d24e369
107 files changed
+5625 -4569

No files matched your search

+5 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -40,11 +40,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
};
break;
case "ping":
@@ -133,11 +135,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
};
}
+136 -14
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -41,8 +42,11 @@ import {
type Monitor,
type MonitorType,
type GamesMap,
type HttpMethod,
supportsGeoCheck,
DefaultPageSpeedStrategy,
DefaultHttpMethod,
HttpMethods,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag";
@@ -258,7 +262,7 @@ const CreateMonitorPage = () => {
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, watch, handleSubmit, clearErrors, trigger, reset } = form;
const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form;
useEffect(() => {
reset(defaults);
@@ -270,6 +274,7 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -1091,27 +1096,144 @@ const CreateMonitorPage = () => {
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="useAdvancedMatching"
name="method"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Select
{...field}
value={field.value ?? DefaultHttpMethod}
onChange={(e) => {
const value = e.target.value as HttpMethod;
field.onChange(value);
// HEAD has no response body, reset advanced matching fields if selected
if (value === "HEAD") {
setValue("useAdvancedMatching", false);
setValue("matchMethod", "");
setValue("expectedValue", "");
setValue("jsonPath", "");
}
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.method.label"
)}
>
{HttpMethods.map((method) => (
<MenuItem
key={method}
value={method}
>
{method}
</MenuItem>
))}
</Select>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
`pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}`
)}
</Typography>
</Stack>
)}
/>
{watchedUseAdvancedMatching && (
<Controller
name="customUpCodes"
control={control}
render={({ field }) => {
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
field.value?.includes(option.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={ALL_HTTP_STATUS_CODES}
value={selectedOptions}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(
_: unknown,
newValue: typeof ALL_HTTP_STATUS_CODES
) => {
field.onChange(newValue?.map((v) => v.id) || []);
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.customUpCodes.label"
)}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.customUpCodes.description"
)}
</Typography>
{selectedOptions.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.MD)}
width="100%"
>
{selectedOptions.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
<Typography flexGrow={1}>{option.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value?.filter((id) => id !== option.id)
);
}}
aria-label={t(
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
)}
>
<Trash2 size={16} />
</IconButton>
{index < selectedOptions.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
{watchedMethod !== "HEAD" && (
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
)}
{watchedUseAdvancedMatching && watchedMethod !== "HEAD" && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="matchMethod"
@@ -33,6 +33,26 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
subtitle={mongoStats.readyState.toString()}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
subtitle={mongoStats.readsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
subtitle={mongoStats.writesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.host")}
subtitle={mongoStats.host}
+18 -41
View File
@@ -42,12 +42,15 @@ export const TableJobs = ({
maxWidth: 220,
};
const isRunning = (row: QueueJobWithId) =>
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
if (isRunning(row)) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
@@ -72,18 +75,6 @@ export const TableJobs = ({
</Typography>
),
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
@@ -92,50 +83,36 @@ export const TableJobs = ({
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
id: "lastFinishedAt",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-";
return (
<Typography
title={v}
title={lastFinishedAt}
sx={cellSx}
>
{v}
{lastFinishedAt}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
id: "nextScheduledAt",
content: t("pages.logs.table.headers.nextScheduledAt"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
return (
<Typography
title={v}
title={nextScheduledAt}
sx={cellSx}
>
{v}
{nextScheduledAt}
</Typography>
);
},
@@ -169,7 +146,7 @@ export const TableJobs = ({
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
...(isRunning(row) && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
@@ -324,7 +324,9 @@ export const MonitorTable = ({
</Stack>
),
render: (row) => {
return row.type;
return row.type === "http" && row.method
? `${row.type} (${row.method})`
: row.type;
},
},
{
+5
View File
@@ -23,6 +23,11 @@ export interface V8HeapStats {
export interface MongoStats {
readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string;
port: number;
dbName: string;
+6
View File
@@ -48,6 +48,10 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export interface Monitor {
id: string;
userId: string;
@@ -64,6 +68,7 @@ export interface Monitor {
jsonPath?: string;
expectedValue?: string;
matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string;
port?: number;
isActive: boolean;
@@ -71,6 +76,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes?: number[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
+1 -8
View File
@@ -30,23 +30,16 @@ export type QueueMetrics = {
export type QueueJobSummary = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedBy: string | null;
lockedUntil: number | null;
lockedAt: number | null;
nextScheduledAt: number;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface QueueData {
+93
View File
@@ -74,3 +74,96 @@ export const getStatusCodeValueType = (code: number): ValueType => {
if (code < 400) return "neutral";
return "negative";
};
export interface HttpStatusCodeOption {
id: number;
name: string;
}
/**
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
*/
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
{ id: 100, name: "100 - Continue" },
{ id: 101, name: "101 - Switching Protocols" },
{ id: 102, name: "102 - Processing" },
{ id: 103, name: "103 - Early Hints" },
{ id: 200, name: "200 - OK" },
{ id: 201, name: "201 - Created" },
{ id: 202, name: "202 - Accepted" },
{ id: 203, name: "203 - Non-Authoritative Information" },
{ id: 204, name: "204 - No Content" },
{ id: 205, name: "205 - Reset Content" },
{ id: 206, name: "206 - Partial Content" },
{ id: 207, name: "207 - Multi-Status" },
{ id: 208, name: "208 - Already Reported" },
{ id: 226, name: "226 - IM Used" },
{ id: 300, name: "300 - Multiple Choices" },
{ id: 301, name: "301 - Moved Permanently" },
{ id: 302, name: "302 - Found" },
{ id: 303, name: "303 - See Other" },
{ id: 304, name: "304 - Not Modified" },
{ id: 305, name: "305 - Use Proxy" },
{ id: 307, name: "307 - Temporary Redirect" },
{ id: 308, name: "308 - Permanent Redirect" },
{ id: 400, name: "400 - Bad Request" },
{ id: 401, name: "401 - Unauthorized" },
{ id: 402, name: "402 - Payment Required" },
{ id: 403, name: "403 - Forbidden" },
{ id: 404, name: "404 - Not Found" },
{ id: 405, name: "405 - Method Not Allowed" },
{ id: 406, name: "406 - Not Acceptable" },
{ id: 407, name: "407 - Proxy Authentication Required" },
{ id: 408, name: "408 - Request Timeout" },
{ id: 409, name: "409 - Conflict" },
{ id: 410, name: "410 - Gone" },
{ id: 411, name: "411 - Length Required" },
{ id: 412, name: "412 - Precondition Failed" },
{ id: 413, name: "413 - Payload Too Large" },
{ id: 414, name: "414 - URI Too Long" },
{ id: 415, name: "415 - Unsupported Media Type" },
{ id: 416, name: "416 - Range Not Satisfiable" },
{ id: 417, name: "417 - Expectation Failed" },
{ id: 418, name: "418 - I'm a Teapot" },
{ id: 419, name: "419 - Page Expired (Laravel)" },
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
{ id: 421, name: "421 - Misdirected Request" },
{ id: 422, name: "422 - Unprocessable Entity" },
{ id: 423, name: "423 - Locked" },
{ id: 424, name: "424 - Failed Dependency" },
{ id: 425, name: "425 - Too Early" },
{ id: 426, name: "426 - Upgrade Required" },
{ id: 428, name: "428 - Precondition Required" },
{ id: 429, name: "429 - Too Many Requests" },
{ id: 431, name: "431 - Request Header Fields Too Large" },
{ id: 440, name: "440 - Login Time-out (IIS)" },
{ id: 449, name: "449 - Retry With (IIS)" },
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
{ id: 500, name: "500 - Internal Server Error" },
{ id: 501, name: "501 - Not Implemented" },
{ id: 502, name: "502 - Bad Gateway" },
{ id: 503, name: "503 - Service Unavailable" },
{ id: 504, name: "504 - Gateway Timeout" },
{ id: 505, name: "505 - HTTP Version Not Supported" },
{ id: 506, name: "506 - Variant Also Negotiates" },
{ id: 507, name: "507 - Insufficient Storage" },
{ id: 508, name: "508 - Loop Detected" },
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
{ id: 510, name: "510 - Not Extended" },
{ id: 511, name: "511 - Network Authentication Required" },
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
{ id: 529, name: "529 - Site is overloaded" },
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
];
+17 -1
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { GeoContinents } from "@/Types/GeoCheck";
import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor";
import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -53,9 +59,15 @@ const geoCheckFields = {
};
// HTTP monitor schema
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
message: "Must be a valid HTTP status code",
});
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
matchMethod: z
@@ -64,6 +76,10 @@ const httpSchema = baseSchema.extend({
.register(monitorStepRegistry, { step: 2 }),
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
customUpCodes: z
.array(httpStatusCode)
.optional()
.register(monitorStepRegistry, { step: 2 }),
...geoCheckFields,
});
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "معرّف المراقب",
"runCount": "عدد التشغيلات",
"failCount": "عدد حالات الفشل",
"lastRunAt": "آخر تشغيل في",
"lockedAt": "مقفل في",
"lastFinishedAt": "آخر انتهاء في",
"lastRunTook": "مدة آخر تشغيل",
"lastFailedAt": "آخر فشل في",
"failReason": "سبب الفشل",
"state": "الحالة"
-3
View File
@@ -945,10 +945,7 @@
"monitorId": "ID del monitor",
"runCount": "Recompte d'execucions",
"failCount": "Recompte",
"lastRunAt": "Darrera execució",
"lockedAt": "Bloquejat el",
"lastFinishedAt": "Darrera finalització",
"lastRunTook": "Durada de la darrera execució",
"lastFailedAt": "Darrera fallada",
"failReason": "Motiu de la fallada",
"state": "Estat"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID monitoru",
"runCount": "Počet spuštění",
"failCount": "Počet selhání",
"lastRunAt": "Poslední spuštění",
"lockedAt": "Uzamčeno v",
"lastFinishedAt": "Naposledy dokončeno",
"lastRunTook": "Poslední běh trval",
"lastFailedAt": "Poslední selhání",
"failReason": "Důvod selhání",
"state": "Stav"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitor-ID",
"runCount": "Ausführungen",
"failCount": "Fehlerzahl",
"lastRunAt": "Letzter Lauf am",
"lockedAt": "Gesperrt am",
"lastFinishedAt": "Zuletzt abgeschlossen am",
"lastRunTook": "Letzter Lauf dauerte",
"lastFailedAt": "Letzter Fehler am",
"failReason": "Fehlergrund",
"state": "Status"
+18 -3
View File
@@ -490,6 +490,13 @@
"advanced": {
"description": "Optional settings for advanced use cases",
"option": {
"method": {
"label": "Request method",
"description": {
"GET": "GET fetches the full response including the body. Advanced matching fully supported",
"HEAD": "HEAD fetches only response headers for reducing load on the target. Supports status code matching but not advanced matching as there is no body."
}
},
"advancedMatching": {
"label": "Use advanced matching"
},
@@ -505,6 +512,11 @@
"equal": "Equal",
"include": "Include",
"regex": "Regex"
},
"customUpCodes": {
"label": "Accepted status codes",
"description": "Add any non-2xx codes that should also be considered successful.",
"removeAriaLabel": "Remove status code"
}
},
"title": "Advanced settings"
@@ -975,6 +987,11 @@
},
"mongoDBStats": {
"readyState": "Ready state",
"readsPerSecond": "Reads/s",
"insertsPerSecond": "Inserts/s",
"updatesPerSecond": "Updates/s",
"deletesPerSecond": "Deletes/s",
"writesPerSecond": "Total writes/s",
"host": "Host",
"port": "Port",
"dbName": "Database name",
@@ -1015,11 +1032,9 @@
"monitorId": "Monitor ID",
"runCount": "Run count",
"failCount": "Count",
"lastRunAt": "Last run at",
"lockedAt": "Locked at",
"nextScheduledAt": "Next run at",
"lockedBy": "Locked by",
"lastFinishedAt": "Last finished at",
"lastRunTook": "Last run took",
"lastFailedAt": "Last failed",
"failReason": "Fail reason"
}
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID de monitor",
"runCount": "Conteo de ejecuciones",
"failCount": "Conteo de fallas",
"lastRunAt": "Última ejecución",
"lockedAt": "Bloqueado a las",
"lastFinishedAt": "Última finalización",
"lastRunTook": "Última ejecución tomó",
"lastFailedAt": "Última falla",
"failReason": "Razón de falla",
"state": "Estado"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitorin tunnus",
"runCount": "Suorituskerrat",
"failCount": "Epäonnistumiskerrat",
"lastRunAt": "Viimeisin suoritus",
"lockedAt": "Lukittu",
"lastFinishedAt": "Viimeisin valmistuminen",
"lastRunTook": "Viimeisin suoritus kesti",
"lastFailedAt": "Viimeisin epäonnistuminen",
"failReason": "Epäonnistumisen syy",
"state": "Tila"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID du moniteur",
"runCount": "Nombre d'exécutions",
"failCount": "Nombre d'échecs",
"lastRunAt": "Dernière exécution",
"lockedAt": "Verrouillé le",
"lastFinishedAt": "Dernière fin",
"lastRunTook": "Durée dernière exécution",
"lastFailedAt": "Dernier échec",
"failReason": "Raison de l'échec",
"state": "État"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "モニターID",
"runCount": "実行回数",
"failCount": "失敗回数",
"lastRunAt": "最終実行日時",
"lockedAt": "ロック日時",
"lastFinishedAt": "最終完了日時",
"lastRunTook": "最終実行時間",
"lastFailedAt": "最終失敗日時",
"failReason": "失敗理由",
"state": "状態"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID do monitor",
"runCount": "Contagem de execuções",
"failCount": "Contagem de falhas",
"lastRunAt": "Última execução em",
"lockedAt": "Bloqueado em",
"lastFinishedAt": "Última conclusão em",
"lastRunTook": "Última execução durou",
"lastFailedAt": "Última falha em",
"failReason": "Motivo da falha",
"state": "Estado"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID монитора",
"runCount": "Кол-во запусков",
"failCount": "Кол-во ошибок",
"lastRunAt": "Последний запуск",
"lockedAt": "Заблокировано в",
"lastFinishedAt": "Последнее завершение",
"lastRunTook": "Последний запуск занял",
"lastFailedAt": "Последняя ошибка",
"failReason": "Причина ошибки",
"state": "Состояние"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "รหัสมอนิเตอร์",
"runCount": "จำนวนรัน",
"failCount": "จำนวนล้มเหลว",
"lastRunAt": "รันล่าสุดเมื่อ",
"lockedAt": "ล็อกเมื่อ",
"lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ",
"lastRunTook": "รันล่าสุดใช้เวลา",
"lastFailedAt": "ล้มเหลวล่าสุดเมื่อ",
"failReason": "สาเหตุที่ล้มเหลว",
"state": "สถานะ"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitör ID",
"runCount": "Çalışma sayısı",
"failCount": "Başarısızlık sayısı",
"lastRunAt": "Son çalışma zamanı",
"lockedAt": "Kilitlenme zamanı",
"lastFinishedAt": "Son tamamlanma zamanı",
"lastRunTook": "Son çalışma süresi",
"lastFailedAt": "Son başarısızlık zamanı",
"failReason": "Başarısızlık nedeni",
"state": "Durum"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID монітора",
"runCount": "Кількість запусків",
"failCount": "Кількість помилок",
"lastRunAt": "Останній запуск",
"lockedAt": "Заблоковано о",
"lastFinishedAt": "Останнє завершення",
"lastRunTook": "Останній запуск тривав",
"lastFailedAt": "Остання помилка",
"failReason": "Причина помилки",
"state": "Стан"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID trình giám sát",
"runCount": "Số lần chạy",
"failCount": "Số lần lỗi",
"lastRunAt": "Chạy lần cuối lúc",
"lockedAt": "Khóa lúc",
"lastFinishedAt": "Hoàn thành lần cuối lúc",
"lastRunTook": "Lần chạy cuối mất",
"lastFailedAt": "Lỗi lần cuối lúc",
"failReason": "Lý do lỗi",
"state": "Trạng thái"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "监控 ID",
"runCount": "运行次数",
"failCount": "失败次数",
"lastRunAt": "上次运行于",
"lockedAt": "锁定于",
"lastFinishedAt": "上次完成于",
"lastRunTook": "上次运行耗时",
"lastFailedAt": "上次失败于",
"failReason": "失败原因",
"state": "状态"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "監控 ID",
"runCount": "執行次數",
"failCount": "失敗次數",
"lastRunAt": "最後執行於",
"lockedAt": "鎖定於",
"lastFinishedAt": "最後完成於",
"lastRunTook": "最後執行耗時",
"lastFailedAt": "最後失敗於",
"failReason": "失敗原因",
"state": "狀態"
+2 -1
View File
@@ -11,7 +11,8 @@ LOG_LEVEL=debug
# Database
# --------
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
QUEUE_TYPE=superSimpleQueue
QUEUE_MODE="primary"
QUEUE_PRIMARY_PROCESSES="true"
# JWT Authentication
# ------------------
+1 -1
View File
@@ -1,5 +1,5 @@
{
"ignore": ["node_modules/*"],
"watch": ["src/**/*.ts", "src/**/*.js", "*.json"],
"watch": ["src/**/*.ts", "src/**/*.js", "*.json", ".env"],
"ext": "ts,js,json"
}
+23 -1
View File
@@ -386,6 +386,10 @@
"type": "string",
"enum": ["equal", "include", "regex"]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"notifications": {
"type": "array",
"items": {
@@ -466,6 +470,9 @@
},
"updatedAt": {
"type": "string"
},
"lastEvaluatedAt": {
"type": "number"
}
},
"required": [
@@ -480,6 +487,7 @@
"statusWindowThreshold",
"ignoreTlsErrors",
"useAdvancedMatching",
"method",
"notifications",
"tags",
"cpuAlertThreshold",
@@ -493,7 +501,8 @@
"teamId",
"userId",
"createdAt",
"updatedAt"
"updatedAt",
"lastEvaluatedAt"
],
"additionalProperties": {
"nullable": true
@@ -7066,6 +7075,10 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"gameId": {
"anyOf": [
{
@@ -7666,6 +7679,11 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"],
"default": "GET"
},
"url": {
"type": "string",
"minLength": 1
@@ -8278,6 +8296,10 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"port": {
"type": "number",
"minimum": 1,
+1215 -988
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -34,7 +34,6 @@
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.15.2",
"bcryptjs": "3.0.2",
"cacheable-lookup": "7.0.0",
@@ -49,12 +48,10 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"less-simple-scheduler": "2.6.0",
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
@@ -63,7 +60,6 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.9.2",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -97,6 +93,7 @@
"globals": "^15.14.0",
"jest": "^30.2.0",
"lint-staged": "^16.2.7",
"mongodb-memory-server": "11.2.0",
"nodemon": "^3.1.11",
"prettier": "^3.3.3",
"prettier-plugin-ini": "1.3.0",
+4 -3
View File
@@ -1,6 +1,6 @@
import mongoose from "mongoose";
import { MonitorModel } from "../dist/db/models/Monitor.js";
import { UserModel } from "../dist/db/models/User.js";
import { MonitorModel } from "../dist/domain/monitors/monitor.model.js";
import { UserModel } from "../dist/domain/users/user.model.js";
// Seeds a large number of HTTP monitors against real, resolving domains so the
// scheduler can be exercised under load. Run after `npm run build` (imports the
@@ -14,7 +14,7 @@ import { UserModel } from "../dist/db/models/User.js";
// NOTE: the running queue only schedules monitors it finds at init(), so restart
// the server after seeding for the new monitors to actually start checking.
const DEFAULT_COUNT = 5_000;
const DEFAULT_COUNT = 500;
const DEFAULT_BATCH = 1_000;
const DEFAULT_INTERVAL = 60_000;
const NAME_PREFIX = "Seed";
@@ -241,6 +241,7 @@ async function run() {
url: `https://${domain}`,
isActive: true,
interval,
lastEvaluatedAt: 0, // evaluator watermark; without it findUnevaluatedByMonitorId gets new Date(undefined)
});
if (docs.length === batchSize) {
+7 -10
View File
@@ -1,4 +1,4 @@
import { IWorker } from "@/worker/worker.interface.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js";
import { Request, Response, NextFunction } from "express";
@@ -13,10 +13,7 @@ export interface IJobQueueController {
class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
private worker: IWorker;
constructor(worker: IWorker) {
this.worker = worker;
}
constructor(private scheduler: IJobScheduler) {}
get serviceName() {
return JobQueueController.SERVICE_NAME;
@@ -24,7 +21,7 @@ class JobQueueController implements IJobQueueController {
getMetrics = async (req: Request, res: Response, next: NextFunction) => {
try {
const metrics = await this.worker.getMetrics();
const metrics = await this.scheduler.getMetrics();
res.status(200).json({
success: true,
msg: "Queue metrics fetched successfully",
@@ -38,7 +35,7 @@ class JobQueueController implements IJobQueueController {
getJobs = async (req: Request, res: Response, next: NextFunction) => {
try {
const pagination = getQueueJobsQueryValidation.parse(req.query);
const { jobs, count } = await this.worker.getJobs(pagination);
const { jobs, count } = await this.scheduler.getJobs(pagination);
return res.status(200).json({
success: true,
msg: "Queue jobs fetched successfully",
@@ -52,8 +49,8 @@ class JobQueueController implements IJobQueueController {
getAllMetrics = async (req: Request, res: Response, next: NextFunction) => {
try {
const pagination = getQueueJobsQueryValidation.parse(req.query);
const { jobs, count } = await this.worker.getJobs(pagination);
const metrics = await this.worker.getMetrics();
const { jobs, count } = await this.scheduler.getJobs(pagination);
const metrics = await this.scheduler.getMetrics();
return res.status(200).json({
success: true,
msg: "Queue metrics fetched successfully",
@@ -66,7 +63,7 @@ class JobQueueController implements IJobQueueController {
flushQueue = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.worker.flushQueues();
const result = await this.scheduler.flushQueues();
return res.status(200).json({
success: true,
msg: "Queue flushed successfully",
+2 -2
View File
@@ -1,12 +1,12 @@
import type { NextFunction, Request, Response } from "express";
import { MulterError } from "multer";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
// Multer throws MulterError, send 413 Payload Too Large / 400
const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400);
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
const handleErrors = (logger: ILogger) => (error: unknown, req: Request, res: Response, _next: NextFunction) => {
const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500;
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService";
+22 -3
View File
@@ -3,6 +3,7 @@ import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import {
DnsRecordTypes,
HttpMethods,
HttpStatusCodeSet,
MonitorMatchMethods,
MonitorStatuses,
@@ -70,6 +71,16 @@ const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.R
}
};
const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boolean; jsonPath?: string }, ctx: z.RefinementCtx) => {
if (body.method === "HEAD" && (body.useAdvancedMatching === true || (body.jsonPath ?? "") !== "")) {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "HEAD requests have no response body, so they cannot use advanced matching or a JSON path",
});
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -95,6 +106,7 @@ export const createMonitorBodyValidation = z
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
method: z.enum(HttpMethods).optional(),
gameId: z.union([z.string(), z.literal("")]).optional(),
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
strategy: z.enum(PageSpeedStrategies).optional(),
@@ -107,7 +119,8 @@ export const createMonitorBodyValidation = z
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType);
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching);
export const editMonitorBodyValidation = z
.object({
@@ -127,6 +140,7 @@ export const editMonitorBodyValidation = z
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
method: z.enum(HttpMethods).optional(),
port: z.number().min(1).max(65535).optional(),
cpuAlertThreshold: z.number().optional(),
memoryAlertThreshold: z.number().optional(),
@@ -144,7 +158,8 @@ export const editMonitorBodyValidation = z
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType);
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -184,6 +199,7 @@ const importedMonitorSchema = z
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
method: z.enum(HttpMethods).optional().default("GET"),
url: z.string().min(1, "URL is required"),
port: z.number().optional(),
isActive: z.boolean().default(true),
@@ -215,7 +231,8 @@ const importedMonitorSchema = z
updatedAt: z.string().optional(),
})
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType);
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -251,6 +268,7 @@ export const monitorResponseSchema = z
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
matchMethod: z.enum(MonitorMatchMethods).optional(),
method: z.enum(HttpMethods),
notifications: z.array(z.string()),
tags: z.array(z.string()),
customUpCodes: z.array(httpStatusCode).optional(),
@@ -272,5 +290,6 @@ export const monitorResponseSchema = z
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastEvaluatedAt: z.number(),
})
.passthrough();
+4 -4
View File
@@ -9,21 +9,21 @@ import { handleErrors } from "@/api/middleware/handleErrors.js";
import { generalApiLimiter } from "@/api/middleware/rateLimiter.js";
import { sanitizeBody, sanitizeQuery } from "@/api/middleware/sanitization.js";
import { setupRoutes } from "@/config/routes.js";
import { InitializedServices } from "@/config/services.js";
import { InitializedControllers } from "@/config/controllers.js";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js";
import { ApiServices } from "@/config/services.api.js";
export const createApp = ({
services,
apiServices: services,
controllers,
envSettings,
frontendPath,
openApiSpec,
}: {
services: InitializedServices;
apiServices: ApiServices;
controllers: InitializedControllers;
envSettings: EnvConfig;
frontendPath: string;
@@ -115,6 +115,6 @@ export const createApp = ({
app.get("*", (req, res) => {
res.sendFile(path.join(frontendPath, "index.html"));
});
app.use(handleErrors);
app.use(handleErrors(services.logger));
return app;
};
+16 -16
View File
@@ -12,7 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import type { InitializedServices } from "@/config/services.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
authController: AuthController;
@@ -30,21 +30,21 @@ export interface InitializedControllers {
diagnosticController: DiagnosticController;
incidentController: IncidentController;
}
export const initializeControllers = (services: InitializedServices): InitializedControllers => {
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
return {
authController: new AuthController(services.userService),
monitorController: new MonitorController(services.monitorService, services.notificationsService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
geoCheckController: new GeoCheckController(services.geoChecksService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.worker),
logController: new LogController(services.logger),
statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService),
notificationController: new NotificationController(services.notificationsService, services.monitorsRepository),
tagController: new TagsController(services.tagsService),
diagnosticController: new DiagnosticController(services.diagnosticService),
incidentController: new IncidentController(services.incidentService),
authController: new AuthController(apiServices.userService),
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService),
checkController: new CheckController(apiServices.checkService),
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
inviteController: new InviteController(apiServices.inviteService),
maintenanceWindowController: new MaintenanceWindowController(apiServices.maintenanceWindowService),
queueController: new QueueController(apiServices.worker),
logController: new LogController(apiServices.logger),
statusPageController: new StatusPageController(apiServices.statusPageService, apiServices.monitorsRepository, apiServices.settingsService),
notificationController: new NotificationController(apiServices.notificationsService, apiServices.monitorsRepository),
tagController: new TagsController(apiServices.tagsService),
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
};
};
+24 -8
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { DbTypes, QueueTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js";
import { DbTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js";
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
const envSchema = z.object({
// Server Configuration
@@ -12,8 +13,8 @@ const envSchema = z.object({
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
DB_TYPE: z.enum(DbTypes).default("mongodb"),
QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"),
QUEUE_MODE: z.enum(QueueModes).default("primary"),
QUEUE_PRIMARY_PROCESSES: booleanCoercion.default(true),
// JWT Authentication
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
@@ -31,24 +32,39 @@ const envSchema = z.object({
export type ValidatedEnv = z.infer<typeof envSchema>;
export const validateEnv = (): ValidatedEnv => {
export const validateEnv = (logger: ILogger): ValidatedEnv => {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Environment validation failed:");
console.error("");
logger.error({
message: "Env validation failed",
method: "validateEnv",
service: "Server",
});
const errors = result.error.format();
for (const [key, value] of Object.entries(errors)) {
if (key !== "_errors" && value && typeof value === "object" && "_errors" in value) {
console.error(` ${key}: ${value._errors.join(", ")}`);
logger.error({
message: `${key}: ${value._errors.join(", ")}`,
method: "validateEnv",
service: "Server",
});
}
}
console.error("Please check your .env file and ensure all required variables are set.");
logger.error({
message: "Please check your .env file and ensure all required variables are set.",
method: "validateEnv",
service: "Server",
});
process.exit(1);
}
console.log("Environment variables validated successfully");
logger.info({
message: "Environment variables validated successfully",
method: "validateEnv",
service: "Server",
});
return result.data;
};
+4 -4
View File
@@ -3,7 +3,7 @@ import { createVerifyJWT } from "../api/middleware/verifyJWT.js";
import { createVerifyStatusPageAccess } from "../api/middleware/verifyStatusPageAccess.js";
import { authApiLimiter } from "../api/middleware/rateLimiter.js";
import type { InitializedControllers } from "./controllers.js";
import type { InitializedServices } from "./services.js";
import type { ApiServices } from "@/config/services.api.js";
import AuthRoutes from "../api/routes/authRoute.js";
import InviteRoutes from "../api/routes/inviteRoute.js";
@@ -21,8 +21,8 @@ import TagRoutes from "../api/routes/tagRoutes.js";
import IncidentRoutes from "../api/routes/incidentRoute.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => {
const verifyJWT = createVerifyJWT(services.settingsService);
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
const authRoutes = new AuthRoutes(controllers.authController, verifyJWT);
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
@@ -32,7 +32,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
const queueRoutes = new QueueRoutes(controllers.queueController);
const logRoutes = new LogRoutes(controllers.logController);
const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT);
const verifyStatusPageAccess = createVerifyStatusPageAccess(apiServices.statusPagesRepository, verifyJWT);
const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess);
const notificationRoutes = new NotificationRoutes(controllers.notificationController);
const tagRoutes = new TagRoutes(controllers.tagController);
+109
View File
@@ -0,0 +1,109 @@
import { DiagnosticService, IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { IInviteService, InviteService } from "@/domain/invites/invite.service.js";
import { IMaintenanceWindowService, MaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js";
import { IMonitorService, MonitorService } from "@/domain/monitors/monitor.service.js";
import { IStatusPageService, StatusPageService } from "@/domain/status-pages/status-page.service.js";
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
// Third-party
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { SharedServices } from "@/config/services.shared.js";
export interface ApiServices extends SharedServices {
worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only)
userService: IUserService;
monitorService: IMonitorService;
maintenanceWindowService: IMaintenanceWindowService;
inviteService: IInviteService;
statusPageService: IStatusPageService;
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
const {
logger,
db,
settingsService,
emailService,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
tagsRepository,
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
jobsRepository,
} = shared;
const userService = new UserService({
crypto,
emailService,
settingsService,
logger,
jwt,
scheduler: jobScheduler,
monitorsRepository,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
teamsRepository,
});
// ***********************
// Business services
// ***********************
const monitorService = new MonitorService({
scheduler: jobScheduler,
logger,
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
const maintenanceWindowService = new MaintenanceWindowService({
monitorsRepository,
maintenanceWindowsRepository,
jobsRepository,
scheduler: jobScheduler,
});
const inviteService = new InviteService({
invitesRepository,
settingsService,
emailService,
});
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
return {
...shared,
worker: jobScheduler,
userService,
monitorService,
maintenanceWindowService,
inviteService,
statusPageService,
tagsService,
diagnosticService,
};
};
+218
View File
@@ -0,0 +1,218 @@
import { Mongoose } from "mongoose";
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
import pkg from "handlebars";
const { compile } = pkg;
import mjml2html from "mjml";
import MongoDB from "@/db/db.mongo.js";
import { IDb } from "@/db/db.interface.js";
import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ICheckService, CheckService } from "@/domain/checks/check.service.js";
import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
import { IEmailService, EmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
// Notification providers
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
import { PagerDutyProvider } from "@/domain/notifications/providers/pagerduty.js";
import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
// Repository interfaces
import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { IRecoveryTokensRepository } from "@/domain/recovery-tokens/recovery-token.repository.interface.js";
import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
import MongoMaintenanceWindowsRepository from "@/domain/maintenance-windows/maintenance-window.repository.mongo.js";
import MongoMonitorStatsRepository from "@/domain/monitor-stats/monitor-stats.repository.mongo.js";
import MongoMonitorsRepository from "@/domain/monitors/monitor.repository.mongo.js";
import MongoNotificationsRepository from "@/domain/notifications/notification.repository.mongo.js";
import MongoQueueWorkersRepository from "@/domain/queue-workers/queue-worker.repository.mongo.js";
import MongoRecoveryTokensRepository from "@/domain/recovery-tokens/recovery-token.repository.mongo.js";
import MongoStatusPagesRepository from "@/domain/status-pages/status-page-repository.mongo.js";
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
logger: ILogger;
db: IDb<Mongoose>;
settingsService: ISettingsService;
emailService: IEmailService;
notificationMessageBuilder: INotificationMessageBuilder;
incidentService: IIncidentService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
notificationsService: INotificationsService;
// Queue identity (one per process; the worker reuses these)
workerId: string;
jobsRepository: IJobsRepository;
queueWorkersRepository: IQueueWorkersRepository;
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
invitesRepository: IInvitesRepository;
recoveryTokensRepository: IRecoveryTokensRepository;
settingsRepository: ISettingsRepository;
notificationsRepository: INotificationsRepository;
tagsRepository: ITagsRepository;
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
}
export const buildShared = async ({
logger,
envSettings,
settingsService,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
}): Promise<SharedServices> => {
// Shared worker ID
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
// Create DB
let db: IDb<Mongoose> | null = null;
db = new MongoDB(logger, envSettings);
await db.connect();
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const invitesRepository = new MongoInvitesRepository();
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
const settingsRepository = new MongoSettingsRepository();
const tagsRepository = new MongoTagsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
// Services
const notificationMessageBuilder = new NotificationMessageBuilder();
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
const checkService = new CheckService(monitorsRepository, logger, checksRepository);
const globalPingService = new GlobalPingService(logger);
const geoChecksService = new GeoChecksService({
logger,
geoChecksRepository,
globalPingService,
monitorsRepository,
});
const webhookProvider = new WebhookProvider(logger);
const slackProvider = new SlackProvider(logger);
const emailProvider = new EmailProvider(emailService, logger);
const discordProvider = new DiscordProvider(logger);
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService,
logger,
notificationMessageBuilder
);
const sharedServices: SharedServices = {
logger,
db,
settingsService,
emailService,
notificationMessageBuilder,
incidentService,
checkService,
geoChecksService,
notificationsService,
workerId,
jobsRepository,
monitorsRepository,
queueWorkersRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
notificationsRepository,
tagsRepository,
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
};
return sharedServices;
};
-450
View File
@@ -1,450 +0,0 @@
import { Mongoose } from "mongoose";
import MongoDB from "../db/db.mongo.js";
import { IDb } from "@/db/db.interface.js";
import { EnvConfig, ISettingsService, SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { CheckService, ICheckService } from "@/domain/checks/check.service.js";
import { DiagnosticService, IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { GeoChecksService, IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
import { IInviteService, InviteService } from "@/domain/invites/invite.service.js";
import { IMaintenanceWindowService, MaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js";
import { IMonitorService, MonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
import { PagerDutyProvider } from "@/domain/notifications/providers/pagerduty.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { IStatusPageService, StatusPageService } from "@/domain/status-pages/status-page.service.js";
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { BufferService, IBufferService } from "@/service/bufferService.js";
import { EmailService, IEmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
import { IWorker } from "@/worker/worker.interface.js";
import { LessSimpleWorker } from "@/worker/worker.less-simple.js";
import { SuperSimpleQueue } from "@/worker/worker.super-simple.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
// Third-party
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
import Docker from "dockerode";
import net from "net";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
import pkg from "handlebars";
const { compile } = pkg;
import mjml2html from "mjml";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
// Repositories
import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js";
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js";
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import MongoMaintenanceWindowsRepository from "@/domain/maintenance-windows/maintenance-window.repository.mongo.js";
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import MongoMonitorStatsRepository from "@/domain/monitor-stats/monitor-stats.repository.mongo.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import MongoMonitorsRepository from "@/domain/monitors/monitor.repository.mongo.js";
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
import MongoNotificationsRepository from "@/domain/notifications/notification.repository.mongo.js";
import MongoQueueWorkersRepository from "@/domain/queue-workers/queue-worker.repository.mongo.js";
import { IRecoveryTokensRepository } from "@/domain/recovery-tokens/recovery-token.repository.interface.js";
import MongoRecoveryTokensRepository from "@/domain/recovery-tokens/recovery-token.repository.mongo.js";
import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import MongoStatusPagesRepository from "@/domain/status-pages/status-page-repository.mongo.js";
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import { type QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { CheckPipeline, GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
export type InitializedServices = {
settingsService: ISettingsService;
db: IDb<Mongoose>;
networkService: INetworkService;
emailService: IEmailService;
bufferService: IBufferService;
statusService: IStatusService;
worker: IWorker;
userService: IUserService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
diagnosticService: IDiagnosticService;
inviteService: IInviteService;
maintenanceWindowService: IMaintenanceWindowService;
monitorService: IMonitorService;
incidentService: IIncidentService;
logger: ILogger;
notificationsService: INotificationsService;
tagsService: ITagsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
invitesRepository: IInvitesRepository;
recoveryTokensRepository: IRecoveryTokensRepository;
settingsRepository: ISettingsRepository;
notificationsRepository: INotificationsRepository;
tagsRepository: ITagsRepository;
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
};
export const initializeServices = async ({
logger,
envSettings,
settingsService,
queueMode = "primary",
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
queueMode?: QueueMode;
}): Promise<InitializedServices> => {
// Create DB
let db: IDb<Mongoose> | null = null;
db = new MongoDB(logger, envSettings);
await db.connect();
// ** NOTE **
// const dbType = envSettings.dbType;
// DB type has been fixed to MongoDB for now
// if (dbType === "mongodb") {
// db = new MongoDB(logger, envSettings);
// }
// if (!db) {
// throw new AppError({ message: "Unsupported database type", status: 500 });
// }
// await db.connect();
// Repositories
// ** NOTE **
// DB Type fixed to MongoDB for now
// let monitorsRepository: IMonitorsRepository;
// let checksRepository: IChecksRepository;
// let geoChecksRepository: IGeoChecksRepository;
// let monitorStatsRepository: IMonitorStatsRepository;
// let statusPagesRepository: IStatusPagesRepository;
// let usersRepository: IUsersRepository;
// let invitesRepository: IInvitesRepository;
// let recoveryTokensRepository: IRecoveryTokensRepository;
// let settingsRepository: ISettingsRepository;
// let notificationsRepository: INotificationsRepository;
// let tagsRepository: ITagsRepository;
// let incidentsRepository: IIncidentsRepository;
// let teamsRepository: ITeamsRepository;
// let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
// if (dbType === "mongodb") {
// monitorsRepository = new MongoMonitorsRepository();
// checksRepository = new MongoChecksRepository(logger);
// geoChecksRepository = new MongoGeoChecksRepository(logger);
// monitorStatsRepository = new MongoMonitorStatsRepository();
// statusPagesRepository = new MongoStatusPagesRepository();
// usersRepository = new MongoUsersRepository();
// invitesRepository = new MongoInvitesRepository();
// recoveryTokensRepository = new MongoRecoveryTokensRepository();
// settingsRepository = new MongoSettingsRepository();
// notificationsRepository = new MongoNotificationsRepository();
// tagsRepository = new MongoTagsRepository();
// incidentsRepository = new MongoIncidentsRepository();
// teamsRepository = new MongoTeamsRepository();
// maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// } else {
// throw new AppError({ message: "Unsupported database type", status: 500 });
// }
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
const invitesRepository = new MongoInvitesRepository();
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
const settingsRepository = new MongoSettingsRepository();
const notificationsRepository = new MongoNotificationsRepository();
const tagsRepository = new MongoTagsRepository();
const incidentsRepository = new MongoIncidentsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const queueWorkersRepository = new MongoQueueWorkersRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
// Network providers
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
pingProvider,
httpProvider,
pageSpeedProvider,
hardwareProvider,
dockerProvider,
portProvider,
gameProvider,
grpcProvider,
webSocketProvider,
dnsProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
const checkService = new CheckService(monitorsRepository, logger, checksRepository);
const globalPingService = new GlobalPingService(logger);
const geoChecksService = new GeoChecksService({
logger,
geoChecksRepository,
globalPingService,
monitorsRepository,
});
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
// Notification providers
const webhookProvider = new WebhookProvider(logger);
const slackProvider = new SlackProvider(logger);
const emailProvider = new EmailProvider(emailService, logger);
const discordProvider = new DiscordProvider(logger);
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService,
logger,
notificationMessageBuilder
);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
// reactors and dispatcher
const notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
// pipelines
const checkPipeline = new CheckPipeline(
monitorsRepository,
maintenanceWindowsRepository,
checkService,
networkService,
bufferService,
monitorStatusPolicy,
statusService,
logger
);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
const workerHelper = new WorkerHelper(
logger,
checkService,
settingsService,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksRepository,
reactorDispatcher,
checkPipeline,
geoCheckPipeline
);
if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") {
throw new AppError({
message: `Worker mode requires QUEUE_TYPE="lessSimpleQueue" got ${envSettings.queueType}`,
status: 500,
service: "config services",
method: "initializeServices",
});
}
let worker: IWorker;
switch (envSettings.queueType) {
case "lessSimpleQueue":
worker = await LessSimpleWorker.create(logger, workerHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode);
break;
default:
worker = await SuperSimpleQueue.create(logger, workerHelper, monitorsRepository);
}
// Business services
const userService = new UserService({
crypto,
emailService,
settingsService,
logger,
jwt,
worker,
monitorsRepository,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
teamsRepository,
});
const diagnosticService = new DiagnosticService(db);
const inviteService = new InviteService({
invitesRepository,
settingsService,
emailService,
});
const maintenanceWindowService = new MaintenanceWindowService({
monitorsRepository,
maintenanceWindowsRepository,
});
const monitorService = new MonitorService({
worker,
logger,
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const services = {
settingsService,
db,
networkService,
emailService,
bufferService,
statusService,
worker,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
monitorService,
incidentService,
logger,
notificationsService,
tagsService,
statusPageService,
notificationMessageBuilder,
// Repositories
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
notificationsRepository,
tagsRepository,
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
};
return services;
};
+151
View File
@@ -0,0 +1,151 @@
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
import Docker from "dockerode";
import net from "net";
import { GameDig } from "gamedig";
import jmespath from "jmespath";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
import { CheckProducer } from "@/worker/worker.check-producer.js";
import { CheckEvaluator } from "@/worker/worker.check-evaluator.js";
import { GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
bufferService: IBufferService;
statusService: IStatusService;
}
export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig): Promise<WorkerServices> => {
const {
logger,
settingsService,
checkService,
geoChecksService,
notificationsService,
incidentService,
workerId,
jobsRepository,
queueWorkersRepository,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
} = shared;
// ***********************
// Network providers
// ***********************
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
pingProvider,
httpProvider,
pageSpeedProvider,
hardwareProvider,
dockerProvider,
portProvider,
gameProvider,
grpcProvider,
webSocketProvider,
dnsProvider,
]);
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
// ***********************
// Reactors and dispatcher
// Handles notifications and incidents
// ***********************
const notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
// ***********************
// Check producer/evaluator
// Handles creating and evaluatiog checks
// ***********************
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
// ***********************
// Worker
// ***********************
const workerHelper = new WorkerHelper(
logger,
checkService,
settingsService,
monitorsRepository,
jobsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksRepository
);
const worker = await DBQueueWorker.create(
logger,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
reactorDispatcher,
workerHelper,
queueWorkersRepository,
envSettings.queueMode,
envSettings.queuePrimaryProcesses,
workerId
);
return { worker, networkService, bufferService, statusService };
};
+1 -1
View File
@@ -42,7 +42,7 @@ class MongoDB implements IDb<Mongoose> {
// the unique index on MonitorStats.monitorId is enforced, otherwise databases
// with stray duplicates (caused by the pre-fix race in updateRunningStats)
// will crash at startup when the unique index fails to build.
await runMigrations();
await runMigrations(this.logger);
// Sync indexes
const models = mongoose.modelNames();
@@ -1,12 +1,12 @@
import { MonitorStatsModel } from "../../domain/monitor-stats/monitor-stats.model.js";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Cleanup duplicate MonitorStats documents
* Keeps the most recent document (by updatedAt) for each monitorId
* and deletes all older duplicates
*/
export async function cleanupDuplicateMonitorStats(): Promise<void> {
export async function cleanupDuplicateMonitorStats(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStats";
try {
@@ -1,11 +1,11 @@
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Fix infrastructure monitors that had thresholds incorrectly set to 0 or 5
* due to the old default value. Updates them to 100 (disabled).
*/
export async function fixInfrastructureThresholds(): Promise<void> {
export async function fixInfrastructureThresholds(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:FixInfrastructureThresholds";
try {
@@ -1,8 +1,8 @@
import mongoose from "mongoose";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
import StatusPageModel from "../../domain/status-pages/status-page.model.js";
export async function migrateStatusPageTypeToArray(): Promise<void> {
export async function migrateStatusPageTypeToArray(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:MigrateStatusPageTypeToArray";
try {
@@ -1,5 +1,5 @@
import { MonitorStatsModel } from "../../domain/monitor-stats/monitor-stats.model.js";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced.
@@ -11,7 +11,7 @@ import { logger } from "@/utils/logger.js";
* Keeps the document with the highest totalChecks for each monitorId (most data) and
* deletes the rest. Falls back to most recent updatedAt when totalChecks tie.
*/
export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise<void> {
export async function cleanupDuplicateMonitorStatsForUniqueIndex(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex";
try {
@@ -1,6 +1,6 @@
import mongoose from "mongoose";
import type { AnyBulkWriteOperation, Document } from "mongodb";
import { logger } from "@/utils/logger.js";
import type { ILogger } from "@/utils/logger.js";
import MaintenanceWindowModel from "../../domain/maintenance-windows/maintenance-window.model.js";
type GroupResult = {
@@ -18,7 +18,7 @@ type GroupResult = {
monitorIds: mongoose.Types.ObjectId[];
};
export async function migrateMaintenanceWindowMonitorIdToArray(): Promise<void> {
export async function migrateMaintenanceWindowMonitorIdToArray(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray";
try {
@@ -0,0 +1,41 @@
import mongoose from "mongoose";
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import type { ILogger } from "@/utils/logger.js";
const JOBS_COLLECTION = "jobs";
/**
* Backfill lastEvaluatedAt on monitors created before the lastEvaluatedAt existed.
*
* Stamp the upgrade moment, not 0. If set to 0, all checks will be replayed
*/
export async function backfillMonitorLastEvaluatedAt(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:BackfillMonitorLastEvaluatedAt";
try {
// Drop the legacy jobs collection first. The old super-simple-scheduler persisted jobs to the
// same "jobs" collection the new db-queue uses, so any leftover rows carry an incompatible schema.
// Migrations run before syncIndexes(), so the new Job indexes are rebuilt right after this drop.
const db = mongoose.connection.db;
if (db) {
const existing = await db.listCollections({ name: JOBS_COLLECTION }).toArray();
if (existing.length > 0) {
await db.collection(JOBS_COLLECTION).drop();
logger.info({ service: SERVICE_NAME, message: `Dropped legacy "${JOBS_COLLECTION}" collection` });
}
}
logger.info({ service: SERVICE_NAME, message: "Starting lastEvaluatedAt backfill" });
const result = await MonitorModel.updateMany({ lastEvaluatedAt: { $exists: false } }, { $set: { lastEvaluatedAt: Date.now() } });
logger.info({
service: SERVICE_NAME,
message: `Backfilled lastEvaluatedAt on ${result.modifiedCount} monitors`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error backfilling lastEvaluatedAt: ${errorMessage}` });
throw error;
}
}
+10 -8
View File
@@ -6,11 +6,12 @@ import MigrationModel from "../../domain/migrations/migration.model.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
name: string;
execute: () => Promise<void>;
execute: (logger: ILogger) => Promise<void>;
};
const migrations: MigrationEntry[] = [
@@ -21,36 +22,37 @@ const migrations: MigrationEntry[] = [
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
];
const runMigrations = async (logger?: ILogger) => {
const runMigrations = async (logger: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: "Migrations" });
logger.info({ message: "Running migrations", service: "Migrations" });
for (const migration of migrations) {
const exists = await MigrationModel.findOne({ name: migration.name, status: "completed" });
if (exists) {
logger?.info({ message: `Skipping ${migration.name}`, service: "Migrations" });
logger.info({ message: `Skipping ${migration.name}`, service: "Migrations" });
continue;
}
try {
await migration.execute();
await migration.execute(logger);
await MigrationModel.findOneAndUpdate(
{ name: migration.name },
{ status: "completed", completedAt: new Date(), error: undefined },
{ upsert: true }
);
logger?.info({ message: `Completed ${migration.name}`, service: "Migrations" });
logger.info({ message: `Completed ${migration.name}`, service: "Migrations" });
} catch (error) {
const err = error as Error;
await MigrationModel.findOneAndUpdate({ name: migration.name }, { status: "failed", error: err?.message }, { upsert: true });
throw error;
}
}
logger?.info({ message: "Migrations completed", service: "Migrations" });
logger.info({ message: "Migrations completed", service: "Migrations" });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: "Migrations", details: { error: err?.message }, stack: err?.stack });
logger.error({ message: "Migration failed", service: "Migrations", details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
@@ -1,5 +1,5 @@
import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js";
import { Settings, SettingsUpdate, type DbType, type QueueMode, type QueueType } from "@/domain/app-settings/app-settings.type.js";
import { Settings, SettingsUpdate, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { AppError } from "@/utils/AppError.js";
import { ValidatedEnv } from "@/config/envValidation.js";
import type { StringValue } from "ms";
@@ -13,8 +13,8 @@ export type EnvConfig = {
clientHost: string;
dbConnectionString: string;
dbType: DbType;
queueType: QueueType;
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
};
@@ -41,8 +41,8 @@ export class SettingsService implements ISettingsService {
clientHost: env.CLIENT_HOST,
dbConnectionString: env.DB_CONNECTION_STRING,
dbType: env.DB_TYPE,
queueType: env.QUEUE_TYPE,
queueMode: env.QUEUE_MODE,
queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
};
}
@@ -1,9 +1,6 @@
export const DbTypes = ["mongodb"] as const;
export type DbType = (typeof DbTypes)[number];
export const QueueTypes = ["lessSimpleQueue", "superSimpleQueue"] as const;
export type QueueType = (typeof QueueTypes)[number];
export const QueueModes = ["primary", "worker"] as const;
export type QueueMode = (typeof QueueModes)[number];
+1
View File
@@ -241,6 +241,7 @@ const CheckSchema = new Schema<CheckDocument>(
},
errors: {
type: [errorSchema],
suppressReservedKeysWarning: true,
default: () => [],
},
capture: {
@@ -42,6 +42,7 @@ export interface IChecksRepository {
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
// update
//delete
deleteByMonitorId(monitorId: string): Promise<number>;
@@ -386,6 +386,16 @@ class MongoChecksRepository implements IChecksRepository {
};
};
findUnevaluatedByMonitorId = async (monitorId: string, since: number) => {
const docs = await CheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gt: new Date(since) },
})
.sort({ createdAt: 1 })
.lean<CheckDocument[]>();
return docs.map(this.toEntity);
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) });
return result.deletedCount;
+27 -3
View File
@@ -11,7 +11,9 @@ const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
toLastEvaluatedAt(check: Check): number;
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
getChecksByMonitor(params: {
monitorId: string;
teamId: string;
@@ -56,7 +58,11 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
toLastEvaluatedAt = (check: Check) => {
return new Date(check.createdAt).getTime();
};
toCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const now = new Date().toISOString();
@@ -82,7 +88,7 @@ export class CheckService implements ICheckService {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "buildCheck",
method: "toCheck",
});
return undefined;
}
@@ -131,6 +137,24 @@ export class CheckService implements ICheckService {
return check;
};
toStatusResponse = (check: Check) => {
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
monitorId: check.metadata.monitorId,
teamId: check.metadata.teamId,
type: check.metadata.type,
status: check.status,
code: check.statusCode,
message: check.message,
responseTime: check.responseTime,
// re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data
payload:
check.metadata.type === "hardware"
? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload)
: undefined,
};
return statusResponse;
};
getChecksByMonitor = async ({
monitorId,
teamId,
@@ -6,6 +6,22 @@ import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "diagnosticService";
export interface MongoStats {
reads: number;
inserts: number;
updates: number;
deletes: number;
timestamp: number;
}
export interface MongoOpsPerSecond {
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
}
export interface CollectionDiagnostics {
name: string;
documentCount: number;
@@ -17,6 +33,11 @@ export interface CollectionDiagnostics {
export interface MongoDiagnostics {
readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string;
port: number;
dbName: string;
@@ -44,6 +65,8 @@ export interface IDiagnosticService {
export class DiagnosticService implements IDiagnosticService {
static SERVICE_NAME = SERVICE_NAME;
private lastMongoOpsPerSecond: MongoStats | null = null;
constructor(private db: IDb<Mongoose>) {
/**
* Performance Observer for monitoring system performance metrics.
@@ -68,6 +91,48 @@ export class DiagnosticService implements IDiagnosticService {
return ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100;
};
private getMongoOpsPerSecond = async (): Promise<MongoOpsPerSecond | null> => {
try {
const mongo = await this.db.getConnection();
const db = mongo.connection.db;
if (!db) {
throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" });
}
const sample = async (): Promise<MongoStats> => {
const res = await db.command({ serverStatus: 1 });
return {
reads: (res.opcounters?.query ?? 0) + (res.opcounters?.getmore ?? 0),
inserts: res.opcounters?.insert ?? 0,
updates: res.opcounters?.update ?? 0,
deletes: res.opcounters?.delete ?? 0,
timestamp: Date.now(),
};
};
// First call has no baseline, seed one so we always have a rate
if (this.lastMongoOpsPerSecond === null) {
this.lastMongoOpsPerSecond = await sample();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const previous = this.lastMongoOpsPerSecond;
const current = await sample();
this.lastMongoOpsPerSecond = current;
const elapsedSeconds = (current.timestamp - previous.timestamp) / 1000;
return {
readsPerSecond: (current.reads - previous.reads) / elapsedSeconds,
insertsPerSecond: (current.inserts - previous.inserts) / elapsedSeconds,
updatesPerSecond: (current.updates - previous.updates) / elapsedSeconds,
deletesPerSecond: (current.deletes - previous.deletes) / elapsedSeconds,
writesPerSecond:
(current.inserts - previous.inserts + current.updates - previous.updates + current.deletes - previous.deletes) / elapsedSeconds,
};
} catch {
return null;
}
};
private getMongoDBStats = async (): Promise<MongoDiagnostics> => {
const mongo = await this.db.getConnection();
const db = mongo.connection.db;
@@ -75,6 +140,8 @@ export class DiagnosticService implements IDiagnosticService {
throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" });
}
const opsPerSecond = await this.getMongoOpsPerSecond();
const dbStats = await db.stats();
const rawCollections = await db.collections();
@@ -100,6 +167,11 @@ export class DiagnosticService implements IDiagnosticService {
return {
readyState: mongo.connection.readyState,
readsPerSecond: opsPerSecond?.readsPerSecond ?? 0,
insertsPerSecond: opsPerSecond?.insertsPerSecond ?? 0,
updatesPerSecond: opsPerSecond?.updatesPerSecond ?? 0,
deletesPerSecond: opsPerSecond?.deletesPerSecond ?? 0,
writesPerSecond: opsPerSecond?.writesPerSecond ?? 0,
host: mongo.connection.host,
port: mongo.connection.port,
dbName: mongo.connection.name,
+79
View File
@@ -0,0 +1,79 @@
import { Schema, model } from "mongoose";
import { JOB_TYPES, type Job } from "@/domain/jobs/job.type.js";
export interface JobDocument extends Omit<Job, "id"> {
_id: string;
}
const JobSchema = new Schema<JobDocument>(
{
_id: {
type: String,
required: true,
},
type: {
type: String,
enum: JOB_TYPES,
required: true,
},
// monitorId for monitor-bound jobs; null for global cleanup jobs. Kept as a String pointer (no populate).
refId: {
type: String,
default: null,
},
// paused monitor => false; the claim scan skips inactive rows (replaces pauseJob/resumeJob)
isActive: {
type: Boolean,
default: true,
},
// Scheduling — epoch ms, so the claim/$min queries operate on plain numbers
nextScheduledAt: {
type: Number,
required: true,
},
intervalMs: {
type: Number,
default: null,
},
// Lock / lease
lockedBy: {
type: String,
default: null,
},
lockedUntil: {
type: Number,
default: null,
},
// Observability
runCount: {
type: Number,
default: 0,
},
failCount: {
type: Number,
default: 0,
},
lastFinishedAt: {
type: Number,
default: null,
},
lastFailReason: {
type: String,
default: null,
},
},
{ timestamps: true }
);
// Used for scanning for claims
JobSchema.index({ type: 1, isActive: 1, nextScheduledAt: 1 });
// for findById, upsertEvaluate, deleteById
JobSchema.index({ refId: 1, type: 1 });
const JobModel = model<JobDocument>("Job", JobSchema);
export { JobModel };
export default JobModel;
@@ -0,0 +1,75 @@
import { Job, JobSeed, JobType } from "@/domain/jobs/job.type.js";
export type JobPageQuery = {
page?: number;
rowsPerPage?: number;
};
export type JobPage = {
jobs: Job[];
count: number;
};
export interface IJobsRepository {
// ********************
// Claims
// ********************
// Atomically claim a job
claimDue(type: JobType, now: number): Promise<Job | null>;
// locks up to `limit` due rows in one batch
claimDueBatch(type: JobType, limit: number, now: number): Promise<Job[]>;
// Extends the locks we still hold. Returns how many were renewed
renewLocks(ids: string[], now: number): Promise<number>;
// Completes a repeating job
recordSuccess(id: string, nextScheduledAt: number, intervalMs: number, now: number): Promise<boolean>;
// Record failure, release lease, bump fail count, record reason, reset nextScheduledAt for retry
recordFailure(id: string, error: unknown, now: number): Promise<boolean>;
recordOneShot(id: string, now: number): Promise<boolean>;
// ********************
// Hand off to evaluator // This is accomplished by creating an "evaluation" type job
// ********************
upsertEvaluate(monitorId: string, now: number): Promise<boolean>;
// ********************
// Job CRUD
// ********************
// Create jobs. Takes a JobSeed
upsertJob(job: JobSeed): Promise<boolean>;
// Creates cleanup jobs, these are executed immediately so need their own method
upsertCleanupJob(job: JobSeed): Promise<boolean>;
// Pause/Resume
setActiveById(refId: string, isActive: boolean): Promise<boolean>;
// 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>;
// Delete a single job of type
deleteByIdAndType(refId: string, type: JobType): Promise<boolean>;
// Delete jobs that reference monitors that no longer exist
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
// Get job
findById(refId: string): Promise<Job[]>;
// ********************
// Observability
// ********************
findPage(pagination: JobPageQuery): Promise<JobPage>;
findAll(): Promise<Job[]>;
}
@@ -0,0 +1,306 @@
import { IJobsRepository, JobPageQuery } from "@/domain/jobs/job.repository.interface.js";
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) {}
private toEntity(doc: JobDocument): Job {
return {
id: doc._id,
type: doc.type,
refId: doc.refId,
isActive: doc.isActive,
nextScheduledAt: doc.nextScheduledAt,
intervalMs: doc.intervalMs,
lockedBy: doc.lockedBy,
lockedUntil: doc.lockedUntil,
runCount: doc.runCount,
failCount: doc.failCount,
lastFinishedAt: doc.lastFinishedAt,
lastFailReason: doc.lastFailReason,
};
}
claimDue = async (type: JobType, now: number) => {
const doc = await JobModel.findOneAndUpdate(
{
type,
isActive: true,
nextScheduledAt: { $lte: now }, // Find due jobs
$or: [
{ lockedUntil: null }, // Not locked
{ lockedUntil: { $lt: now } }, // Free or expired lock
],
},
{
$set: {
lockedBy: this.workerId,
lockedUntil: now + LOCK_MS,
},
},
{
new: true,
sort: { nextScheduledAt: 1 }, // Get the oldest due job
}
).lean<JobDocument>();
return doc ? this.toEntity(doc) : null;
};
claimDueBatch = async (type: JobType, limit: number, now: number) => {
if (limit <= 0) return [];
const dueAndFree = {
type,
isActive: true,
nextScheduledAt: { $lte: now }, // due
$or: [{ lockedUntil: null }, { lockedUntil: { $lt: now } }], // free or expired lock
};
// 1. Pick the oldest-due candidates
const candidates = await JobModel.find(dueAndFree).sort({ nextScheduledAt: 1 }).limit(limit).select({ _id: 1 }).lean<{ _id: string }[]>();
if (candidates.length === 0) return [];
const ids = candidates.map((d) => d._id);
// 2. lock them. Re-check free/expired so we never steal another worker's live lock
// only the rows this updateMany actually flips end up with claimed
const lockedUntil = now + LOCK_MS;
await JobModel.updateMany(
{ _id: { $in: ids }, $or: [{ lockedUntil: null }, { lockedUntil: { $lt: now } }] },
{ $set: { lockedBy: this.workerId, lockedUntil } }
);
// 3. Read back exactly the rows we now hold from this batch, keyed by the lockedUntil time
const docs = await JobModel.find({ _id: { $in: ids }, lockedBy: this.workerId, lockedUntil }).lean<JobDocument[]>();
return docs.map(this.toEntity);
};
renewLocks = async (ids: string[], now: number) => {
if (ids.length === 0) return 0;
const res = await JobModel.updateMany(
{ _id: { $in: ids }, lockedBy: this.workerId }, // only locks held by this worker can be renewed
{ $set: { lockedUntil: now + LOCK_MS } }
);
return res.modifiedCount;
};
recordSuccess = async (id: string, scheduledRunAt: number, intervalMs: number, now: number) => {
// Fixed rate job scheduling
let nextScheduledAt = scheduledRunAt + intervalMs;
// If the job fell behind, skip the missed runs. Spread the next run with jitter to avoid a herd
if (nextScheduledAt <= now) {
nextScheduledAt = now + intervalMs + Math.floor(Math.random() * intervalMs);
}
const res = await JobModel.updateOne(
{
_id: id,
lockedBy: this.workerId, // Only the worker that claimed can record success
},
{
$set: {
nextScheduledAt,
lockedBy: null, // Remove lock
lockedUntil: null,
lastFinishedAt: now,
},
$inc: {
runCount: 1, // Increment run count
},
}
);
return res.modifiedCount === 1;
};
recordFailure = async (id: string, error: unknown, now: number) => {
const res = await JobModel.updateOne(
{
_id: id,
lockedBy: this.workerId, // Only the worker that claimed can record success
},
{
$set: {
nextScheduledAt: now + BACKOFF_MS,
lockedBy: null, // Remove lock
lockedUntil: null,
lastFinishedAt: now, // a failed run still finished used in getMetrics for failedAt timestamp
lastFailReason: error instanceof Error ? error.message : String(error),
},
$inc: {
failCount: 1, // Increment fail count
},
}
);
return res.modifiedCount === 1;
};
recordOneShot = async (id: string, now: number) => {
const res = await JobModel.updateOne(
{
_id: id,
lockedBy: this.workerId, // Only the worker that claimed can record success
},
{
$set: {
nextScheduledAt: PARKED,
lockedBy: null, // Remove lock
lockedUntil: null,
lastFinishedAt: now,
},
$inc: {
runCount: 1, // Increment run count
},
}
);
return res.modifiedCount === 1;
};
upsertEvaluate = async (monitorId: string, now: number) => {
const filter = { type: "evaluate", refId: monitorId };
const update = {
$setOnInsert: {
_id: jobId("evaluate", monitorId),
// refId and Type inferred from filter
isActive: true,
intervalMs: null,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
},
$min: {
nextScheduledAt: now, // no op if there is already a pending evaluation that is older
},
};
try {
const res = await JobModel.updateOne(filter, update, { upsert: true });
return res.acknowledged;
} catch (error) {
// Two callers can race the first insert for the same monitor, so the
// loser hits a duplicate _id. Retry once, the row now exists, so this becomes the $min.
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
const res = await JobModel.updateOne(filter, update, { upsert: true });
return res.acknowledged;
}
throw error;
}
};
upsertJob = async (job: JobSeed) => {
const res = await JobModel.updateOne(
{ _id: job.id },
{
$set: {
// These fields are owned by the monitor, always safe to set
type: job.type,
refId: job.refId,
isActive: job.isActive,
intervalMs: job.intervalMs,
},
$setOnInsert: {
// These fields are scheduling related, only set on insert so they aren't clobbered
nextScheduledAt: job.nextScheduledAt,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
},
},
{ upsert: true }
);
return res.acknowledged;
};
upsertCleanupJob = async (job: JobSeed) => {
const res = await JobModel.updateOne(
{ _id: job.id },
{
$set: {
type: job.type,
refId: job.refId,
isActive: job.isActive,
intervalMs: job.intervalMs,
nextScheduledAt: job.nextScheduledAt,
},
$setOnInsert: {
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
},
},
{ upsert: true }
);
return res.acknowledged;
};
setActiveById = async (refId: string, isActive: boolean) => {
const res = await JobModel.updateMany({ refId }, { $set: { isActive } }); // inverts check and geo check rows together
return res.modifiedCount > 0;
};
updateScheduleById = async (refId: string, type: JobType, intervalMs: number | null) => {
const res = await JobModel.updateOne({ refId, type }, { $set: { intervalMs } });
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;
};
deleteByIdAndType = async (refId: string, type: JobType) => {
const res = await JobModel.deleteOne({ refId, type }); // Delete a single typed row, e.g. just the geo row
return res.deletedCount > 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
// remove jobs who'se refId is not null (cleanup jobs) and not in the list of monitor IDs (orphaned monitor jobs)
const res = await JobModel.deleteMany({ refId: { $ne: null, $nin: monitorIds } });
return res.deletedCount ?? 0;
};
findById = async (refId: string) => {
const docs = await JobModel.find({ refId }).lean<JobDocument[]>();
return docs.map(this.toEntity);
};
findPage = async ({ page = 0, rowsPerPage = 0 }: JobPageQuery) => {
const count = await JobModel.countDocuments();
const query = JobModel.find().sort({ nextScheduledAt: 1 });
if (rowsPerPage > 0) query.skip(Math.max(page, 0) * rowsPerPage).limit(rowsPerPage);
const docs = await query.lean<JobDocument[]>();
return { jobs: docs.map(this.toEntity), count };
};
findAll = async () => {
const docs = await JobModel.find().lean<JobDocument[]>();
return docs.map(this.toEntity);
};
}
export default MongoJobsRepository;
+32
View File
@@ -0,0 +1,32 @@
export const JOB_TYPES = ["check", "geo-check", "evaluate", "cleanup-orphaned", "cleanup-retention"] as const;
export type JobType = (typeof JOB_TYPES)[number];
export const LOCK_MS = 60_000;
export const BACKOFF_MS = 5_000;
export const PARKED = Number.MAX_SAFE_INTEGER;
// Canonical _id scheme: monitor-bound rows are `${type}:${refId}`; global rows (refId null) are just the type.
export const jobId = (type: JobType, refId: string | null): string => (refId === null ? type : `${type}:${refId}`);
export type Job = {
id: string;
type: JobType;
refId: string | null;
isActive: boolean;
// Scheduling fields
nextScheduledAt: number; // epoch ms; due when nextScheduledAt <= now
intervalMs: number | null; // repeat period in ms; null = one time job
// Lock fields
lockedBy: string | null; // workerId holding the lease
lockedUntil: number | null; // epoch ms; lease expiry => reclaimable after a crash
// observability (replaces what getMetrics/getJobs)
runCount: number;
failCount: number;
lastFinishedAt: number | null;
lastFailReason: string | null;
};
// These are the only values to set when creating a job. Repo owns the rest
export type JobSeed = Pick<Job, "id" | "type" | "refId" | "isActive" | "intervalMs" | "nextScheduledAt">;
@@ -1,8 +1,10 @@
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";
import { IJobScheduler } from "@/worker/worker.interface.js";
const SERVICE_NAME = "maintenanceWindowService";
@@ -40,16 +42,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
private jobsRepository: IJobsRepository;
private scheduler: IJobScheduler;
constructor({
monitorsRepository,
maintenanceWindowsRepository,
jobsRepository,
scheduler,
}: {
monitorsRepository: IMonitorsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
jobsRepository: IJobsRepository;
scheduler: IJobScheduler;
}) {
this.monitorsRepository = monitorsRepository;
this.maintenanceWindowsRepository = maintenanceWindowsRepository;
this.jobsRepository = jobsRepository;
this.scheduler = scheduler;
}
get serviceName() {
@@ -71,6 +81,11 @@ 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());
// Wake the (possibly idle, backed-off) loops so the rearmed jobs run promptly instead of waiting up to POLL_MAX_MS
this.scheduler.wake("check");
this.scheduler.wake("geo-check");
}
};
+13 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/domain/monitors/monitor.types.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -34,6 +34,7 @@ interface MonitorDocument extends MonitorDocumentBase {
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
lastEvaluatedAt: number; // epoch ms
}
const snapshotTimingPhasesSchema = new Schema<GotTimings["phases"]>(
@@ -196,7 +197,7 @@ const checkSnapshotSchema = new Schema<CheckSnapshotDocument>(
audits: { type: snapshotAuditsSchema },
createdAt: { type: Date, required: true },
},
{ _id: false }
{ _id: false, suppressReservedKeysWarning: true }
);
const MonitorSchema = new Schema<MonitorDocument>(
@@ -220,6 +221,12 @@ const MonitorSchema = new Schema<MonitorDocument>(
description: {
type: String,
},
method: {
type: String,
enum: HttpMethods,
default: "GET",
required: true,
},
status: {
type: String,
enum: MonitorStatuses,
@@ -377,6 +384,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: [checkSnapshotSchema],
default: [],
},
lastEvaluatedAt: {
type: Number,
default: 0,
},
},
{
timestamps: true,
@@ -22,6 +22,8 @@ export interface IMonitorsRepository {
createMonitors(monitors: Monitor[]): Promise<Monitor[]>;
// single fetch
findById(monitorId: string, teamId: string): Promise<Monitor>;
// for workers, don't need all details
findByIdLean(monitorId: string): Promise<Monitor | null>;
// collection fetch
findAll(): Promise<Monitor[] | null>;
@@ -38,6 +38,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(monitor);
};
findByIdLean = async (monitorId: string): Promise<Monitor | null> => {
const monitor = await MonitorModel.findOne({ _id: monitorId });
return monitor ? this.toEntity(monitor) : null;
};
findAll = async (): Promise<Monitor[]> => {
const monitors = await MonitorModel.find();
return this.mapDocuments(monitors);
@@ -531,6 +536,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
teamId: toStringId(doc.teamId),
name: doc.name,
description: doc.description ?? undefined,
method: doc.method ?? "GET",
status: doc.status ?? "initializing",
statusWindow: doc.statusWindow ?? [],
statusWindowSize: doc.statusWindowSize,
@@ -571,6 +577,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
lastEvaluatedAt: doc.lastEvaluatedAt,
};
};
@@ -596,6 +603,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
teamId: toStringId(doc.teamId),
name: doc.name,
description: doc.description ?? undefined,
method: doc.method ?? "GET",
status: doc.status ?? "initializing",
statusWindow: doc.statusWindow ?? [],
statusWindowSize: doc.statusWindowSize,
@@ -636,6 +644,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
lastEvaluatedAt: doc.lastEvaluatedAt,
};
};
+17 -16
View File
@@ -21,8 +21,8 @@ import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-r
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { IWorker } from "@/worker/worker.interface.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
@@ -94,7 +94,7 @@ export interface IMonitorService {
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private worker: IWorker;
private scheduler: IJobScheduler;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
@@ -105,7 +105,7 @@ export class MonitorService implements IMonitorService {
private incidentsRepository: IIncidentsRepository;
constructor({
worker,
scheduler,
logger,
games,
monitorsRepository,
@@ -115,7 +115,7 @@ export class MonitorService implements IMonitorService {
statusPagesRepository,
incidentsRepository,
}: {
worker: IWorker;
scheduler: IJobScheduler;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
@@ -125,7 +125,7 @@ export class MonitorService implements IMonitorService {
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
}) {
this.worker = worker;
this.scheduler = scheduler;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
@@ -171,7 +171,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
}
this.worker.addJob(monitor.id, monitor);
this.scheduler.addJob(monitor.id, monitor);
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
await Promise.all(createdMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor)));
await Promise.all(createdMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor)));
return createdMonitors;
};
@@ -196,7 +196,7 @@ export class MonitorService implements IMonitorService {
}));
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
await Promise.all(demoMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor)));
await Promise.all(demoMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor)));
return demoMonitors;
};
@@ -428,7 +428,7 @@ export class MonitorService implements IMonitorService {
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.worker.updateJob(editedMonitor);
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
};
@@ -448,7 +448,7 @@ export class MonitorService implements IMonitorService {
// If notifications were updated, we should update the jobs in the queue
if (modifiedCount > 0) {
const monitors = await this.monitorsRepository.findByIds(monitorIds);
await Promise.all(monitors.map((monitor) => this.worker.updateJob(monitor)));
await Promise.all(monitors.map((monitor) => this.scheduler.updateJob(monitor)));
}
return modifiedCount;
@@ -457,9 +457,9 @@ export class MonitorService implements IMonitorService {
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
if (monitor.isActive) {
await this.worker.resumeJob(monitor);
await this.scheduler.resumeJob(monitor);
} else {
await this.worker.pauseJob(monitor);
await this.scheduler.pauseJob(monitor);
}
return monitor;
};
@@ -478,9 +478,9 @@ export class MonitorService implements IMonitorService {
const results = await Promise.allSettled(
monitors.map(async (monitor) => {
if (monitor.isActive) {
await this.worker.resumeJob(monitor);
await this.scheduler.resumeJob(monitor);
} else {
await this.worker.pauseJob(monitor);
await this.scheduler.pauseJob(monitor);
}
})
);
@@ -541,7 +541,7 @@ export class MonitorService implements IMonitorService {
});
});
await this.worker.deleteJob(monitor);
await this.scheduler.deleteJob(monitor);
return monitor;
};
@@ -550,7 +550,7 @@ export class MonitorService implements IMonitorService {
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.worker.deleteJob(monitor);
await this.scheduler.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
@@ -597,6 +597,7 @@ export class MonitorService implements IMonitorService {
recentChecks: [],
createdAt: "",
updatedAt: "",
lastEvaluatedAt: 0,
}));
const createdMonitors = await this.createMonitors(cleanedMonitors);
+28 -1
View File
@@ -4,7 +4,29 @@ import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-chec
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import http from "node:http";
export const HttpStatusCodes = Object.keys(http.STATUS_CODES).map(Number);
export const HttpStatusCodes = [
...Object.keys(http.STATUS_CODES).map(Number),
419, // Page Expired (Laravel)
420, // Enhance Your Calm (Twitter)
440, // Login Time-out (IIS)
449, // Retry With (IIS)
460, // Client Closed Connection (AWS ELB)
463, // X-Forwarded-For Too Large (AWS ELB)
497, // HTTP Request Sent to HTTPS Port (NGINX)
499, // Client Closed Request (NGINX)
509, // Bandwidth Limit Exceeded (Apache)
520, // Web Server Returned an Unknown Error (Cloudflare)
521, // Web Server Is Down (Cloudflare)
522, // Connection Timed Out (Cloudflare)
523, // Origin Is Unreachable (Cloudflare)
524, // A Timeout Occurred (Cloudflare)
525, // SSL Handshake Failed (Cloudflare)
526, // Invalid SSL Certificate (Cloudflare)
527, // Railgun Error (Cloudflare)
529, // Site is overloaded
530, // Site is frozen (Cloudflare)
561, // Unauthorized (AWS ELB)
];
export const HttpStatusCodeSet = new Set(HttpStatusCodes);
export type HttpStatusCode = number;
@@ -40,6 +62,9 @@ export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
export type DnsRecordType = (typeof DnsRecordTypes)[number];
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const MAX_RECENT_CHECKS = 50;
export interface Monitor {
@@ -48,6 +73,7 @@ export interface Monitor {
teamId: string;
name: string;
description?: string;
method: HttpMethod;
status: MonitorStatus;
statusWindow: boolean[];
statusWindowSize: number;
@@ -88,6 +114,7 @@ export interface Monitor {
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
lastEvaluatedAt: number; // epoch ms
}
export interface MonitorsSummary {
@@ -2,11 +2,15 @@ import { Schema, model } from "mongoose";
import { QueueModes } from "@/domain/app-settings/app-settings.type.js";
import { QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.type.js";
// Single source of truth for worker liveness: the TTL that GCs stale records and
// the window readers use to count a worker as alive. Heartbeat cadence must stay well below it.
export const WORKER_TTL_SECONDS = 180;
const QueueWorkerSchema = new Schema<QueueWorkerDocument>(
{
_id: { type: String, required: true },
mode: { type: String, enum: QueueModes, required: true },
lastSeenAt: { type: Date, default: Date.now, expires: 30 },
lastSeenAt: { type: Date, default: Date.now, expires: WORKER_TTL_SECONDS },
},
{ timestamps: true }
);
+6 -6
View File
@@ -11,9 +11,9 @@ import { AppError } from "@/utils/AppError.js";
import { IEmailService } from "@/service/emailService.js";
import { EnvConfig, ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { IWorker } from "@/worker/worker.interface.js";
type CryptoType = typeof crypto;
type JwtType = typeof jwt;
const SERVICE_NAME = "userService";
@@ -53,7 +53,7 @@ export class UserService implements IUserService {
private settingsService: ISettingsService;
private logger: ILogger;
private jwt: JwtType;
private worker: IWorker;
private scheduler: IJobScheduler;
private crypto: CryptoType;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
@@ -68,7 +68,7 @@ export class UserService implements IUserService {
settingsService,
logger,
jwt,
worker,
scheduler,
monitorsRepository,
usersRepository,
invitesRepository,
@@ -81,7 +81,7 @@ export class UserService implements IUserService {
settingsService: ISettingsService;
logger: ILogger;
jwt: JwtType;
worker: IWorker;
scheduler: IJobScheduler;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
invitesRepository: IInvitesRepository;
@@ -93,7 +93,7 @@ export class UserService implements IUserService {
this.settingsService = settingsService;
this.logger = logger;
this.jwt = jwt;
this.worker = worker;
this.scheduler = scheduler;
this.crypto = crypto;
this.monitorsRepository = monitorsRepository;
this.usersRepository = usersRepository;
@@ -330,7 +330,7 @@ export class UserService implements IUserService {
if (roles.includes("superadmin")) {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors) {
await Promise.all(monitors.map((monitor) => this.worker.deleteJob(monitor)));
await Promise.all(monitors.map((monitor) => this.scheduler.deleteJob(monitor)));
}
}
// 6. Delete the user by id
+62 -19
View File
@@ -1,4 +1,7 @@
import { initializeServices } from "./config/services.js";
import { buildShared } from "@/config/services.shared.js";
import { buildApi } from "@/config/services.api.js";
import { buildWorker } from "@/config/services.worker.js";
import { initializeControllers } from "./config/controllers.js";
import { createApp } from "./app.js";
import { initShutdownListener } from "@/shutdown.js";
@@ -9,44 +12,84 @@ import fs from "fs";
import Logger, { ILogger } from "@/utils/logger.js";
import { SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { JobScheduler } from "@/worker/worker.job-scheduler.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
const SERVICE_NAME = "Server";
let logger: ILogger;
const startApp = async () => {
// Validate environment variables first
const env = validateEnv();
// ***********************
// Basic setup
// ***********************
// Create settings service (env only — DB repository injected after connect)
logger = new Logger();
const env = validateEnv(logger);
logger.setLogLevel(env.LOG_LEVEL);
const settingsService = new SettingsService(env);
const envSettings = settingsService.loadSettings();
// Create logger
logger = new Logger({ envSettings });
const { queueMode, queuePrimaryProcesses } = envSettings;
// Initialize services (connects DB, creates repositories, injects settingsRepository)
const services = await initializeServices({ logger, envSettings, settingsService, queueMode: envSettings.queueMode });
logger.info({
message: `Queue mode: ${queueMode}`,
service: SERVICE_NAME,
method: "startApp",
});
// If this is a worker instance, we're done. No need for express
if (envSettings.queueMode === "worker") {
logger.info({
message: "Worker instance started. API will not be started",
service: SERVICE_NAME,
});
initShutdownListener(null, services);
logger.info({
message: `Process: ${queuePrimaryProcesses}`,
service: SERVICE_NAME,
method: "startApp",
});
// ***********************
// Build shared services
// ***********************
const shared = await buildShared({ logger, envSettings, settingsService });
// ***********************
// Worker node path, don't need API
// ***********************
if (queueMode === "worker") {
const { worker } = await buildWorker(shared, envSettings);
logger.info({ message: "Worker instance started. API will not be started", service: SERVICE_NAME });
initShutdownListener(null, { worker, db: shared.db, logger });
return;
}
// FE path
// ***********************
// Primary node path
// ***********************
let scheduler: IJobScheduler;
// ***********************
//Primary node processes jobs, need full worker
// ***********************
if (queuePrimaryProcesses === true) {
const { worker } = await buildWorker(shared, envSettings);
scheduler = worker;
}
// ***********************
// Primary node does not process jobs, only need scheduler
// ***********************
else {
scheduler = new JobScheduler(shared.jobsRepository, shared.queueWorkersRepository, shared.workerId);
}
const services = buildApi(shared, scheduler);
// ***********************
// FE assets always needed for primary node
// ***********************
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
const frontendPath = path.join(__dirname, "..", "public");
// Initialize controllers
const controllers = initializeControllers(services);
const app = createApp({
services,
apiServices: services,
controllers,
envSettings,
frontendPath,
+27 -12
View File
@@ -3,6 +3,7 @@ import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { IGeoChecksService } from "../domain/geo-checks/geo-check.service.js";
import type { ILogger } from "@/utils/logger.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { ICheckService } from "../domain/checks/check.service.js";
const SERVICE_NAME = "BufferService";
@@ -24,12 +25,20 @@ export class BufferService implements IBufferService {
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: ICheckService;
private geoChecksService: IGeoChecksService;
private jobsRepository: IJobsRepository;
constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) {
constructor(
logger: ILogger,
checkService: ICheckService,
geoChecksService: IGeoChecksService,
settingsService: ISettingsService,
jobsRepository: IJobsRepository
) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute
this.logger = logger;
this.checksService = checkService;
this.geoChecksService = geoChecksService;
this.jobsRepository = jobsRepository;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.geoBuffer = [];
@@ -93,16 +102,23 @@ export class BufferService implements IBufferService {
}, this.BUFFER_TIMEOUT);
}
async flushBuffer() {
if (this.buffer.length === 0) {
return;
}
// Take the batch first so a write that drains it can't be appended to mid-flush
const batch = this.buffer;
this.buffer = [];
try {
if (this.buffer.length > 0) {
this.logger.debug({
message: `Flushing ${this.buffer.length} checks to database`,
service: this.SERVICE_NAME,
method: "flushBuffer",
});
await this.checksService.createChecks(this.buffer);
this.buffer = [];
}
this.logger.debug({
message: `Flushing ${batch.length} checks to database`,
service: this.SERVICE_NAME,
method: "flushBuffer",
});
await this.checksService.createChecks(batch);
// Need to evaluate checks when they are flushed
const monitorIds = [...new Set(batch.map((check) => check.metadata.monitorId))];
const now = Date.now();
await Promise.all(monitorIds.map((monitorId) => this.jobsRepository.upsertEvaluate(monitorId, now)));
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -110,8 +126,7 @@ export class BufferService implements IBufferService {
method: "flushBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
// Clear buffer even on error to prevent infinite retry loops
this.buffer = [];
// Batch is already cleared from the buffer; dropping it prevents infinite retry loops
}
}
@@ -53,6 +53,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
}
): MonitorStatusResponse<T> {
const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts;
// Return early for HEAD requests, no body to parse
if (monitor.method === "HEAD") {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: statusUp,
code: statusCode,
message,
responseTime,
timings,
payload: body as T | string,
};
}
const isJson = contentType.includes("application/json");
if (monitor.jsonPath && !isJson) {
@@ -155,6 +171,8 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.method = monitor.method;
try {
const response = await this.got<string>(url, options);
const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes);
+9 -20
View File
@@ -1,4 +1,3 @@
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js";
@@ -17,7 +16,6 @@ import type {
} from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "./bufferService.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.types.js";
@@ -42,30 +40,21 @@ export interface IStatusService {
| GrpcStatusPayload
| undefined
>,
check: Check
check: Check,
monitor: Monitor
): Promise<StatusChangeResult>;
}
export class StatusService implements IStatusService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private buffer: IBufferService;
private monitorsRepository: IMonitorsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
constructor(
logger: ILogger,
buffer: IBufferService,
monitorsRepository: IMonitorsRepository,
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository
) {
constructor(logger: ILogger, monitorsRepository: IMonitorsRepository, monitorStatsRepository: IMonitorStatsRepository) {
this.logger = logger;
this.buffer = buffer;
this.monitorsRepository = monitorsRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
}
get serviceName() {
@@ -212,11 +201,11 @@ export class StatusService implements IStatusService {
| GrpcStatusPayload
| undefined
>,
check: Check
check: Check,
monitor: Monitor
): Promise<StatusChangeResult> => {
try {
const { monitorId, teamId, status, code } = statusResponse;
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
const { status, code } = statusResponse;
// Update running stats
await this.tryUpdateRunningStats(monitor, statusResponse);
@@ -232,10 +221,10 @@ export class StatusService implements IStatusService {
// Build the status patch — computed against the projected window
const patch: Partial<Monitor> = {};
// Resolve "initializing" up-front, incidents should be created on initialization && down
// Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down
let newStatus: MonitorStatus = monitor.status;
let statusChanged = false;
if (monitor.status === "initializing") {
if (monitor.status === "initializing" || monitor.status === "maintenance") {
newStatus = status === true ? "up" : "down";
patch.status = newStatus;
statusChanged = newStatus === "down";
@@ -262,7 +251,7 @@ export class StatusService implements IStatusService {
};
}
// First evaluate reachability-based status changes, which apply to all monitor types
// First evaluate reachability status changes, which apply to all monitor types
// and take precedence over hardware breaches.
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
if (reachabilityResult.transitioned) {
+11 -3
View File
@@ -1,9 +1,17 @@
import { InitializedServices } from "./config/services.js";
import { logger } from "./utils/logger.js";
import { IDb } from "@/db/db.interface.js";
import type { ILogger } from "@/utils/logger.js";
import type { Server } from "http";
import { Mongoose } from "mongoose";
import { IJobScheduler } from "@/worker/worker.interface.js";
export const initShutdownListener = (server: Server | null, services: InitializedServices) => {
type ShutdownTargets = {
worker: IJobScheduler;
db: IDb<Mongoose>;
logger: ILogger;
};
export const initShutdownListener = (server: Server | null, services: ShutdownTargets) => {
const SERVICE_NAME = "Server";
const logger = services.logger;
let isShuttingDown = false;
+7 -12
View File
@@ -1,7 +1,6 @@
import { createLogger, format, transports, Logger as WinstonLogger } from "winston";
import type { Logform } from "winston";
import dotenv from "dotenv";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
dotenv.config();
const SERVICE_NAME = "Logger";
@@ -28,18 +27,17 @@ export interface ILogger {
cacheLog(entry: LogEntry): void;
getLogs(): LogEntry[];
buildLogEntry(level: string, config: LogConfig): LogEntry;
setLogLevel(level: string): void;
}
class Logger implements ILogger {
static SERVICE_NAME = SERVICE_NAME;
private logger: WinstonLogger;
private envSettings: Partial<EnvConfig>;
private logCache: LogEntry[];
private maxCacheSize: number;
constructor({ envSettings }: { envSettings: Partial<EnvConfig> }) {
this.envSettings = envSettings;
constructor() {
this.logCache = [];
this.maxCacheSize = 1000;
const consoleFormat = format.printf((info: Logform.TransformableInfo) => {
@@ -84,10 +82,8 @@ class Logger implements ILogger {
return msg;
});
const logLevel = this.envSettings.logLevel || "info";
this.logger = createLogger({
level: logLevel,
level: "info",
format: format.combine(format.timestamp()),
transports: [
new transports.Console({
@@ -151,11 +147,10 @@ class Logger implements ILogger {
timestamp: new Date().toISOString(),
};
}
setLogLevel(level: string): void {
this.logger.level = level || "info";
}
}
export default Logger;
// Legacy logger
const logger = new Logger({ envSettings: { logLevel: "debug" } });
export { logger };
export type { LogConfig, LogEntry };
@@ -0,0 +1,34 @@
import { MonitorStatusResponse } from "@/types/network.js";
import { MonitorEvaluation } from "@/worker/worker.interface.js";
import { Check } from "@/domain/checks/check.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { IStatusService } from "@/service/statusService.js";
export interface ICheckEvaluator {
evaluate(status: MonitorStatusResponse, check: Check, monitor: Monitor): Promise<MonitorEvaluation>;
}
export class CheckEvaluator implements ICheckEvaluator {
constructor(
private statusService: IStatusService,
private monitorStatusPolicy: IMonitorStatusPolicy
) {}
evaluate = async (status: MonitorStatusResponse, check: Check, monitor: Monitor) => {
// ****************************
// Step 3: Evaluate and return result to reactors
// ****************************
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check, monitor);
// Step 5. Get decisions and create an evaluation obj
const decision = this.monitorStatusPolicy.evaluate(statusChangeResult);
const evaluation: MonitorEvaluation = {
monitor: statusChangeResult.monitor,
status,
check,
statusChange: statusChangeResult,
decision,
};
return evaluation;
};
}
@@ -1,14 +1,9 @@
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { MonitorEvaluation } from "@/worker/worker.interface.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
import { ILogger } from "@/utils/logger.js";
import { INetworkService } from "@/service/networkService.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { IBufferService } from "@/service/bufferService.js";
import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { IStatusService } from "@/service/statusService.js";
import { AppError } from "@/utils/AppError.js";
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
@@ -19,89 +14,6 @@ export interface ICheckPipeline {
run(monitor: Monitor): Promise<MonitorEvaluation | null>; // null = skipped
}
export class CheckPipeline implements ICheckPipeline {
constructor(
private monitorsRepository: IMonitorsRepository,
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
private checkService: ICheckService,
private networkService: INetworkService,
private bufferService: IBufferService,
private monitorStatusPolicy: IMonitorStatusPolicy,
private statusService: IStatusService,
private logger: ILogger
) {}
private async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
const now = new Date();
return maintenanceWindows.some((window) => isWindowActive(window, now));
}
run = async (monitor: Monitor): Promise<MonitorEvaluation | null> => {
const monitorId = monitor.id;
if (!monitorId) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" });
}
// ****************************
// Step 1: Acquire
// ****************************
// Step 1a: Maintenance window gate - skip if in maintenance
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId);
if (maintenanceWindowActive) {
this.logger.debug({
message: `Monitor ${monitor.id} is in maintenance window`,
service: SERVICE_NAME,
method: "runCheckPipeline",
});
if (monitor.status !== "maintenance") {
await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" });
}
return null;
}
// Step 1b: Acquire status
const status = await this.networkService.requestStatus(monitor);
if (!status) {
throw new Error("No network response");
}
// ****************************
// Step 2: Record
// ****************************
// Step 2a: Create & record a check, return null if fail
const check = this.checkService.buildCheck(status);
if (!check) {
this.logger.warn({
message: `No check could be built for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "runCHeckPipeline",
details: { code: status.code, message: status.message },
});
return null;
}
// Step 2b: Add to buffer
this.bufferService.addToBuffer(check);
// ****************************
// Step 3: Evaluate and return result to reactors
// ****************************
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
// Step 5. Get decisions and create an evaluation obj
const decision = this.monitorStatusPolicy.evaluate(statusChangeResult);
const evaluation: MonitorEvaluation = {
monitor: statusChangeResult.monitor,
status,
check,
statusChange: statusChangeResult,
decision,
};
return evaluation;
};
}
export class GeoChecksPipeline implements ICheckPipeline {
constructor(
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
@@ -0,0 +1,83 @@
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { Check } from "@/domain/checks/check.type.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "@/service/bufferService.js";
import { INetworkService } from "@/service/networkService.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
export interface ICheckProducer {
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
}
const SERVICE_NAME = "CheckProducer";
export class CheckProducer implements ICheckProducer {
constructor(
private monitorsRepository: IMonitorsRepository,
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
private checkService: ICheckService,
private networkService: INetworkService,
private bufferService: IBufferService,
private logger: ILogger
) {}
private async isInMaintenanceWindow(monitorId: string, teamId: string) {
const windows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
const now = new Date();
return windows.some((w) => isWindowActive(w, now));
}
produce = async (monitor: Monitor) => {
if (!monitor.id) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" });
}
// ****************************
// Step 1: Acquire
// ****************************
// Step 1a: Maintenance window gate - skip if in maintenance
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId);
if (maintenanceWindowActive) {
this.logger.debug({
message: `Monitor ${monitor.id} is in maintenance window`,
service: SERVICE_NAME,
method: "produce",
});
if (monitor.status !== "maintenance") {
// Clear the status window to avoid incidents being created on next check
await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance", statusWindow: [] });
}
return null;
}
// Step 1b: Acquire status
const status = await this.networkService.requestStatus(monitor);
if (!status) {
throw new Error("No network response");
}
// ****************************
// Step 2: Record
// ****************************
// Step 2a: Create & record a check, return null if fail
const check = this.checkService.toCheck(status);
if (!check) {
this.logger.warn({
message: `No check could be built for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "produce",
details: { code: status.code, message: status.message },
});
return null;
}
// Step 2b: Add to buffer
this.bufferService.addToBuffer(check);
return { status, check };
};
}
+290
View File
@@ -0,0 +1,290 @@
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { type Job, type JobSeed, type JobType, jobId, LOCK_MS } from "@/domain/jobs/job.type.js";
import { ILogger } from "@/utils/logger.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { ICheckProducer } from "@/worker/worker.check-producer.js";
import { ICheckEvaluator } from "@/worker/worker.check-evaluator.js";
import { ICheckPipeline } from "@/worker/worker.check-pipeline.js";
import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { IWorkerHelper } from "@/worker/worker.helper.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js";
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { JobScheduler } from "@/worker/worker.job-scheduler.js";
const SERVICE_NAME = "JobQueue";
const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const POLL_MAX_MS = 5000; // back off poll interval to 5s if no jobs
const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window
const HEARTBEAT_MS = WORKER_STALE_MS / 3; // Worker can miss two beats without being considered stale
const LOCK_RENEW_MS = LOCK_MS / 3; // renew an in-flight job's lock at 1/3 the lease, so it survives two missed renewals
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
const CONCURRENCY: Record<JobType, number> = {
check: 500,
"geo-check": 20,
evaluate: 20,
"cleanup-orphaned": 1,
"cleanup-retention": 1,
};
export class DBQueueWorker extends JobScheduler implements IQueueWorker {
static SERVICE_NAME = SERVICE_NAME;
private inFlight: Record<JobType, number> = {
check: 0,
"geo-check": 0,
evaluate: 0,
"cleanup-orphaned": 0,
"cleanup-retention": 0,
};
constructor(
private logger: ILogger,
jobsRepository: IJobsRepository, // ← no modifier: forwarded to super
private monitorsRepository: IMonitorsRepository,
private checksRepository: IChecksRepository,
private checkService: ICheckService,
private checkProducer: ICheckProducer,
private checkEvaluator: ICheckEvaluator,
private geoCheckPipeline: ICheckPipeline,
private dispatcher: IReactorDispatcher,
private helper: IWorkerHelper,
queueWorkersRepository: IQueueWorkersRepository, // ← no modifier: forwarded
private queueMode: QueueMode,
private queuePrimaryProcesses: boolean,
workerId: string
) {
super(jobsRepository, queueWorkersRepository, workerId);
}
get serviceName() {
return DBQueueWorker.SERVICE_NAME;
}
static async create(
logger: ILogger,
jobsRepository: IJobsRepository,
monitorsRepository: IMonitorsRepository,
checksRepository: IChecksRepository,
checkService: ICheckService,
checkProducer: ICheckProducer,
checkEvaluator: ICheckEvaluator,
geoCheckPipeline: ICheckPipeline,
dispatcher: IReactorDispatcher,
helper: IWorkerHelper,
queueWorkersRepository: IQueueWorkersRepository,
queueMode: QueueMode,
queuePrimaryProcesses: boolean,
workerId: string
): Promise<DBQueueWorker> {
const instance = new DBQueueWorker(
logger,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher,
helper,
queueWorkersRepository,
queueMode,
queuePrimaryProcesses,
workerId
);
await instance.init();
return instance;
}
private toCleanupJob = (type: "cleanup-orphaned" | "cleanup-retention", now: number): JobSeed => ({
id: jobId(type, null),
type,
refId: null,
isActive: true,
nextScheduledAt: now,
intervalMs: CLEANUP_INTERVAL_MS,
});
// ********************
// Stage 1: This can eventually be offloaded to a separate service
// ********************
private runCheck = async (job: Job) => {
if (!job.refId) return;
const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId
if (!monitor) return;
await this.checkProducer.produce(monitor);
};
// ********************
// Stage 2: Evaluator loop stays here
// ********************
private runEvaluate = async (job: Job) => {
if (!job.refId) return;
const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId
if (!monitor) return;
const checks = await this.checksRepository.findUnevaluatedByMonitorId(job.refId, monitor.lastEvaluatedAt);
let current = monitor;
for (const check of checks) {
const status = this.checkService.toStatusResponse(check);
const evaluation = await this.checkEvaluator.evaluate(status, check, current);
await this.dispatcher.dispatch(evaluation); // Handle incidents and notifications
await this.monitorsRepository.updateById(job.refId, current.teamId, { lastEvaluatedAt: this.checkService.toLastEvaluatedAt(check) });
current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check
}
};
private runGeoCheck = async (job: Job) => {
const monitor = await this.monitorsRepository.findByIdLean(job.refId!);
if (monitor) await this.geoCheckPipeline.run(monitor); // returns null; no evaluate handoff
};
// Extend the lock while a job runs so a slow-but-alive job is never reclaimed.
private renewLock = async (job: Job): Promise<boolean> => {
try {
const held = await this.jobsRepository.renewLocks([job.id], Date.now());
if (held === 0) {
this.logger.warn({ message: `Lost lock on job ${job.id}, stopping renewal`, service: SERVICE_NAME, method: "renewLock" });
return false;
}
return true;
} catch (error: unknown) {
this.logger.warn({ message: error instanceof Error ? error.message : String(error), service: SERVICE_NAME, method: "renewLock" });
return true;
}
};
private runJob = async (job: Job) => {
const renewTimer = setInterval(async () => {
const renewLock = await this.renewLock(job);
if (!renewLock) clearInterval(renewTimer);
}, LOCK_RENEW_MS);
try {
switch (job.type) {
case "check":
await this.runCheck(job);
break;
case "evaluate":
await this.runEvaluate(job);
break;
case "geo-check":
await this.runGeoCheck(job);
break;
case "cleanup-orphaned":
await this.helper.getCleanupOrphanedJob()(); // Get job and execute
break;
case "cleanup-retention":
await this.helper.getCleanupRetentionJob()(); // Get job and execute
break;
}
if (job.intervalMs === null) {
// One-shot job
await this.jobsRepository.recordOneShot(job.id, Date.now());
} else {
await this.jobsRepository.recordSuccess(job.id, job.nextScheduledAt, job.intervalMs, Date.now());
}
} catch (error: unknown) {
await this.jobsRepository.recordFailure(job.id, error, Date.now()); // Record failure and release lock
this.logger.warn({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: `runJob:${job.type}`,
});
} finally {
clearInterval(renewTimer);
}
};
private startLoop = (type: JobType) => {
this.pollMs[type] = POLL_MS;
const tick = async () => {
if (this.stopped) return;
this.ticking[type] = true;
try {
// Claim a batch sized to the free capacity
const capacity = CONCURRENCY[type] - this.inFlight[type];
const jobs = await this.jobsRepository.claimDueBatch(type, capacity, Date.now());
for (const job of jobs) {
this.inFlight[type]++; // stay within concurrency limits
this.runJob(job).finally(() => {
this.inFlight[type]--; // job done, free the slot
});
}
// Back off if no jobs found, reset to base if jobs found
this.pollMs[type] = capacity > 0 && jobs.length === 0 ? Math.min(this.pollMs[type] * 2, POLL_MAX_MS) : POLL_MS;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: `loop:${type}`,
});
}
// Clearing the flag and re-arming run synchronously (no await between), so wake() can never interleave here.
this.ticking[type] = false;
if (!this.stopped) this.timers.set(type, setTimeout(tick, this.pollMs[type])); // re-arm unless shutting down
};
this.tickFns.set(type, tick);
tick();
};
// Wake a worker loop after adding a job that's due immediately.
// Otherwise the loop could be delayed by POLL_MAX_MS if it is already fully backed off
// Seed queue
private reconcile = async () => {
const now = Date.now();
const monitors = (await this.monitorsRepository.findAll()) ?? [];
for (const monitor of monitors) {
await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now));
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, now));
}
}
// Both cleanup jobs run immediately on every startup, then once a day
await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-orphaned", now));
await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-retention", now));
};
// Register worker
private heartbeat = async () => {
try {
await this.queueWorkersRepository.upsert(this.workerId, this.queueMode);
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: "heartbeat",
});
}
};
init = async () => {
this.stopped = false;
await this.heartbeat();
this.timers.set("heartbeat", setInterval(this.heartbeat, HEARTBEAT_MS));
if (this.queueMode === "primary") {
await this.reconcile();
}
if (this.queuePrimaryProcesses === true || this.queueMode === "worker") {
for (const type of Object.keys(this.inFlight) as JobType[]) {
this.startLoop(type);
}
}
return true;
};
override flushQueues = async () => {
await this.shutdown();
const ok = await this.init();
return { success: ok };
};
}
+15 -48
View File
@@ -1,5 +1,4 @@
const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
@@ -8,15 +7,12 @@ import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.i
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { ICheckPipeline } from "@/worker/worker.check-pipeline.js";
export interface IWorkerHelper {
readonly serviceName: string;
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
getCleanupOrphanedJob(): () => Promise<void>;
getCleanupRetentionJob(): () => Promise<void>;
}
@@ -42,80 +38,41 @@ export class WorkerHelper implements IWorkerHelper {
private checkService: ICheckService;
private settingsService: ISettingsService;
private monitorsRepository: IMonitorsRepository;
private jobsRepository: IJobsRepository;
private teamsRepository: ITeamsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
private geoChecksRepository: IGeoChecksRepository;
private reactorDispatcher: IReactorDispatcher;
private checkPipeline: ICheckPipeline;
private geoCheckPipeline: ICheckPipeline;
constructor(
logger: ILogger,
checkService: ICheckService,
settingsService: ISettingsService,
monitorsRepository: IMonitorsRepository,
jobsRepository: IJobsRepository,
teamsRepository: ITeamsRepository,
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository,
incidentsRepository: IIncidentsRepository,
geoChecksRepository: IGeoChecksRepository,
reactorDispatcher: IReactorDispatcher,
checkPipeline: ICheckPipeline,
geoCheckPipeline: ICheckPipeline
geoChecksRepository: IGeoChecksRepository
) {
this.logger = logger;
this.checkService = checkService;
this.settingsService = settingsService;
this.monitorsRepository = monitorsRepository;
this.jobsRepository = jobsRepository;
this.teamsRepository = teamsRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
this.geoChecksRepository = geoChecksRepository;
this.reactorDispatcher = reactorDispatcher;
this.checkPipeline = checkPipeline;
this.geoCheckPipeline = geoCheckPipeline;
}
get serviceName() {
return WorkerHelper.SERVICE_NAME;
}
getHeartbeatJob = () => {
return async (monitor: Monitor) => {
try {
const evaluation = await this.checkPipeline.run(monitor);
if (evaluation) await this.reactorDispatcher.dispatch(evaluation);
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
};
getHeartbeatGeoJob = () => {
return async (monitor: Monitor) => {
try {
await this.geoCheckPipeline.run(monitor);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
stack: error instanceof Error ? error.stack : undefined,
});
// Don't throw - geo check failures shouldn't crash the job scheduler
}
};
};
getCleanupOrphanedJob = () => {
return async () => {
try {
@@ -190,6 +147,16 @@ export class WorkerHelper implements IWorkerHelper {
});
}
// Remove orphaned jobs
const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
if (deletedJobsCount > 0) {
this.logger.info({
message: `Deleted ${deletedJobsCount} orphaned jobs`,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
}
this.logger.info({
message: "Cleanup of orphaned data completed",
service: SERVICE_NAME,
+11 -12
View File
@@ -1,8 +1,9 @@
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
import { Check } from "@/domain/checks/check.type.js";
import { JobType } from "@/domain/jobs/job.type.js";
import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
import { MonitorActionDecision } from "@/worker/worker.helper.js";
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
export type MonitorEvaluation = {
monitor: Monitor;
@@ -33,23 +34,16 @@ export type WorkerMetrics = {
export type WorkerJobSummary = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedBy: string | null;
lockedUntil: number | null;
lockedAt: number | null;
nextScheduledAt: number;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export type WorkerJobsPagination = {
@@ -62,16 +56,21 @@ export type WorkerJobsPage = {
count: number;
};
export interface IWorker {
export interface IJobScheduler {
readonly serviceName: string;
init(): Promise<boolean>;
addJob(monitorId: string, monitor: Monitor): Promise<void>;
deleteJob(monitor: Monitor): Promise<void>;
pauseJob(monitor: Monitor): Promise<void>;
resumeJob(monitor: Monitor): Promise<void>;
updateJob(monitor: Monitor): Promise<void>;
shutdown(): Promise<void>;
wake(type: JobType): void;
getMetrics(): Promise<WorkerMetrics>;
getJobs(pagination: WorkerJobsPagination): Promise<WorkerJobsPage>;
flushQueues(): Promise<{ success: boolean }>;
shutdown(): Promise<void>;
}
export interface IQueueWorker extends IJobScheduler {
readonly serviceName: string;
init(): Promise<boolean>;
}
+167
View File
@@ -0,0 +1,167 @@
import { IJobScheduler } from "@/worker/worker.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js";
import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js";
const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const SERVICE_NAME = "JobScheduler";
const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window
export class JobScheduler implements IJobScheduler {
static SERVICE_NAME = SERVICE_NAME;
constructor(
protected jobsRepository: IJobsRepository,
protected queueWorkersRepository: IQueueWorkersRepository,
protected readonly workerId: string // same id MongoJobsRepository stamps into lockedBy
) {}
get serviceName() {
return JobScheduler.SERVICE_NAME;
}
protected stopped = false;
protected ticking: Partial<Record<JobType, boolean>> = {}; // Whether a loop is mid tick
protected tickFns = new Map<JobType, () => void>(); // Store tick fns so we can run them immediately on wake
protected timers = new Map<JobType | "heartbeat", NodeJS.Timeout>();
// Per job type poll intervals. These back off on idle and reset on work or wake()
protected pollMs: Record<JobType, number> = {
check: POLL_MS,
"geo-check": POLL_MS,
evaluate: POLL_MS,
"cleanup-orphaned": POLL_MS,
"cleanup-retention": POLL_MS,
};
// Helpers, builds job seeds. immediate=true runs on the next tick; otherwise jitter spreads the herd.
protected toCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({
id: jobId("check", monitor.id),
type: "check",
refId: monitor.id,
isActive: monitor.isActive,
nextScheduledAt: immediate ? now : now + Math.floor(Math.random() * monitor.interval), // Avoid herd
intervalMs: monitor.interval,
});
protected toGeoCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({
...this.toCheckJob(monitor, now, immediate),
id: jobId("geo-check", monitor.id),
type: "geo-check",
intervalMs: monitor.geoCheckInterval!,
});
private toSummary = (job: Job): WorkerJobSummary => ({
monitorId: job.refId ?? job.id,
monitorType: job.type,
monitorInterval: job.intervalMs,
monitorActive: job.isActive,
lockedBy: job.lockedBy,
lockedUntil: job.lockedUntil,
nextScheduledAt: job.nextScheduledAt,
runCount: job.runCount,
failCount: job.failCount,
failReason: job.lastFailReason,
lastFinishedAt: job.lastFinishedAt,
});
wake = (type: JobType) => {
this.pollMs[type] = POLL_MS;
if (this.stopped || this.ticking[type]) return; // If it's not running or mid tick, do nothing
const tick = this.tickFns.get(type);
if (!tick) return; // Try to get a handle on the tick fn, if we can't we're done
const pending = this.timers.get(type);
if (pending) clearTimeout(pending); // Clear out scheduled tick if any
this.timers.set(type, setTimeout(tick, 0)); // Set immediate tick
};
addJob = async (_monitorId: string, monitor: Monitor) => {
const now = Date.now();
await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now, true));
this.wake("check");
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, now, true));
this.wake("geo-check");
}
};
deleteJob = async (monitor: Monitor) => {
await this.jobsRepository.deleteById(monitor.id);
};
pauseJob = async (monitor: Monitor) => {
await this.jobsRepository.setActiveById(monitor.id, false);
};
resumeJob = async (monitor: Monitor) => {
await this.jobsRepository.setActiveById(monitor.id, true); // a paused job's nextScheduledAt is now in the past, so it's due immediately
this.wake("check");
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) this.wake("geo-check");
};
updateJob = async (monitor: Monitor) => {
await this.jobsRepository.updateScheduleById(monitor.id, "check", monitor.interval);
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, Date.now(), true)); // a newly enabled geo check should run right away
this.wake("geo-check");
} else {
await this.jobsRepository.deleteByIdAndType(monitor.id, "geo-check");
}
};
getMetrics = async (): Promise<WorkerMetrics> => {
const rows = await this.jobsRepository.findAll();
const workers = await this.queueWorkersRepository.findRecent(WORKER_STALE_MS);
const now = Date.now();
return rows.reduce<WorkerMetrics>(
(acc, job) => {
acc.jobs++;
acc.totalRuns += job.runCount;
acc.totalFailures += job.failCount;
if (job.lockedBy !== null && job.lockedUntil !== null && job.lockedUntil > now) acc.activeJobs++;
if (job.failCount > 0) {
acc.failingJobs++;
acc.jobsWithFailures.push({
monitorId: job.refId ?? job.id,
monitorUrl: null,
monitorType: job.type,
failedAt: job.lastFinishedAt,
failCount: job.failCount,
failReason: job.lastFailReason,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [],
totalRuns: 0,
totalFailures: 0,
workers,
}
);
};
getJobs = async (pagination: WorkerJobsPagination) => {
const { jobs, count } = await this.jobsRepository.findPage(pagination);
return { jobs: jobs.map(this.toSummary), count };
};
flushQueues = async () => {
await this.shutdown();
return { success: true };
};
shutdown = async () => {
this.stopped = true;
// Stop all timers
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear(); // Clear the map out
// Remove workers from registry
await this.queueWorkersRepository.deleteById(this.workerId);
};
}
-427
View File
@@ -1,427 +0,0 @@
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler";
import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { IWorkerHelper } from "@/worker/worker.helper.js";
import { type WorkerMetrics, type WorkerJobsPagination, type WorkerJobsPage, IWorker } from "@/worker/worker.interface.js";
import { type QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
const SERVICE_NAME = "JobQueue";
// How long after its last heartbeat a worker is still considered alive. Must be longer than the scheduler's heartbeat
const WORKER_TTL_MS = 30_000;
// For discriminating job types
type MonitorJob = Omit<IJob, "data"> & { data: Monitor };
const MONITOR_TEMPLATES = new Set<string>(["monitor-job", "geo-check-job"]);
const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template);
export class LessSimpleWorker implements IWorker {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private helper: IWorkerHelper;
private monitorsRepository: IMonitorsRepository;
private workersRepository: IQueueWorkersRepository;
private readonly scheduler: Scheduler;
private queueMode: QueueMode;
private heartbeatListener: ((workerId: string) => void) | null = null;
constructor(
logger: ILogger,
helper: IWorkerHelper,
monitorsRepository: IMonitorsRepository,
workersRepository: IQueueWorkersRepository,
scheduler: Scheduler,
queueMode: QueueMode
) {
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
this.workersRepository = workersRepository;
this.scheduler = scheduler;
this.queueMode = queueMode;
this.registerListeners();
}
get serviceName() {
return LessSimpleWorker.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (workerId, error) => {
this.logger.error({
message: `${workerId} error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:locked", (workerId, job) => {
this.logger.debug({
message: `${workerId} ${job.id} locked`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:abort", (workerId, job, reason) => {
this.logger.warn({
message: `${workerId} ${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (workerId, job, attempt) => {
this.logger.debug({
message: `${workerId} ${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (workerId, job) => {
this.logger.debug({
message: `${workerId} ${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (workerId, job, error) => {
this.logger.error({
message: `${workerId} ${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (workerId, job, error, attempt) => {
this.logger.warn({
message: `${workerId} ${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (workerId, job) => {
this.logger.debug({
message: `${workerId} ${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(
logger: ILogger,
helper: IWorkerHelper,
monitorsRepository: IMonitorsRepository,
workersRepository: IQueueWorkersRepository,
envSettings: EnvConfig,
queueMode: QueueMode
) {
const store = new MongoStore({
url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db",
});
const scheduler = new Scheduler(store, {
concurrency: envSettings.queueMode === "primary" ? 0 : 50,
processEvery: 100,
});
const instance = new LessSimpleWorker(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode);
await instance.init(queueMode);
return instance;
}
private startHeartbeat = (queueMode: QueueMode) => {
const beat = (
workerId: string // This is the heartbeat function that registers workers
) =>
this.workersRepository.upsert(workerId, queueMode).catch((error: unknown) => {
this.logger.warn({
message: `Worker heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "startHeartbeat",
});
});
beat(this.scheduler.workerId); // Beat immediately to register
if (this.heartbeatListener) {
this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); // remove old listeners
}
this.heartbeatListener = beat; /// Keep a reference to the listener
this.scheduler.on("scheduler:heartbeat", beat); // beat on every heartbeat
};
init = async (queueMode: QueueMode = "primary") => {
try {
await this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
// Register in the worker registry
this.startHeartbeat(queueMode);
// If this is a worker, return early - primary will populate queue
if (queueMode === "worker") {
this.logger.info({
message: `Worker queue: ${this.scheduler.workerId} initialized`,
service: SERVICE_NAME,
method: "init",
});
return true;
}
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
}
const jobInputs = monitors.flatMap((monitor) => this.buildJobInputs(monitor));
if (jobInputs.length > 0) {
await this.scheduler.addJobs(jobInputs);
}
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, upsert: true });
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000, upsert: true });
return true;
} catch (error: unknown) {
this.logger.error({
message: `Failed to initialize LessSimpleQueue: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "init",
});
return false;
}
};
// Builds the scheduler inputs for a monitor: always a monitor-job, plus a
// geo-check job when the monitor supports and enables geo checks.
private buildJobInputs = (monitor: Monitor): AddJobInput[] => {
const inputs: AddJobInput[] = [
{
id: monitor.id,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor,
upsert: true,
jitter: true,
},
];
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
inputs.push({
id: `${monitor.id}-geo`,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
upsert: true,
jitter: true,
});
}
return inputs;
};
addJob = async (_monitorId: string, monitor: Monitor) => {
await this.scheduler.addJobs(this.buildJobInputs(monitor));
};
deleteJob = async (monitor: Monitor) => {
this.scheduler.removeJob(monitor.id);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: Monitor) => {
const result = await this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to pause monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to pause geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor: Monitor) => {
const result = await this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to resume geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
private syncGeoJob = async (monitor: Monitor) => {
const geoJobId = `${monitor.id}-geo`;
const existingGeoJob = await this.scheduler.getJob(geoJobId);
// If geoChecks have been disabled, or the monitor type doesn't support them, remove
if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) {
if (existingGeoJob) this.scheduler.removeJob(geoJobId);
return;
}
// If the job exists, update it
if (existingGeoJob) {
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
return;
}
// Otherwise, create it
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
upsert: true,
});
};
updateJob = async (monitor: Monitor) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
await this.syncGeoJob(monitor);
};
shutdown = async () => {
// Stop beating before we remove this worker, so a heartbeat firing between
// the remove and the scheduler stopping can't re-insert it.
if (this.heartbeatListener) {
this.scheduler.off("scheduler:heartbeat", this.heartbeatListener);
this.heartbeatListener = null;
}
await this.workersRepository.deleteById(this.scheduler.workerId).catch((error: unknown) => {
this.logger.warn({
message: `Failed to deregister worker on shutdown: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "shutdown",
});
});
this.scheduler.stop();
};
getMetrics = async (): Promise<WorkerMetrics> => {
const stats = await this.scheduler.getStats();
const jobsWithFailures = stats.jobsWithFailures.map((failure) => {
// `data` is untyped at the store level: for monitor jobs it's the Monitor,
// for system jobs (e.g. cleanup) it has no url/type => null
const monitor = failure.data as Monitor | undefined;
return {
monitorId: failure.id,
monitorUrl: monitor?.url ?? null,
monitorType: monitor?.type ?? null,
failedAt: failure.failedAt,
failCount: failure.failCount,
failReason: failure.failReason,
};
});
const workers = await this.workersRepository.findRecent(WORKER_TTL_MS);
return {
jobs: stats.jobs,
activeJobs: stats.activeJobs,
failingJobs: stats.failingJobs,
totalRuns: stats.totalRuns,
totalFailures: stats.totalFailures,
jobsWithFailures,
workers,
};
};
getJobs = async ({ page = 0, rowsPerPage = 0 }: WorkerJobsPagination): Promise<WorkerJobsPage> => {
const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined;
const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]);
const mapped = jobs.map((job) => {
const monitor = isMonitorJob(job) ? job.data : undefined;
return {
monitorId: job.id,
monitorUrl: monitor?.url || null,
monitorType: monitor?.type || null,
monitorInterval: monitor?.interval || null,
monitorGeoInterval: monitor?.geoCheckInterval || null,
monitorActive: monitor?.isActive ?? null,
active: job.active,
repeat: job.repeat ?? null,
lockedBy: job.lockedBy ?? null,
lockedUntil: job.lockedUntil ?? null,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
failReason: job.lastError ?? null,
lastRunAt: job.lastStartedAt ?? null,
lastFinishedAt: job.lastFinishedAt ?? null,
lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt,
lastFailedAt: job.lastFailedAt ?? null,
};
});
return { jobs: mapped, count };
};
flushQueues = async () => {
if (this.queueMode !== "primary") {
// Don't allow workers to flush, though this isn't currently reachable since workers have no API endpoints
this.logger.warn({ message: "Ignoring flush on non-primary worker", service: SERVICE_NAME, method: "flushQueues" });
return { success: false };
}
const flushRes = await this.scheduler.flushJobs(); // Less Simple Queue must flush before stopping as it needs DB
const stopRes = await this.scheduler.stop(); // Destructive, other workers will abort their jobs
const initRes = await this.init();
return {
success: Boolean(stopRes && flushRes && initRes),
};
};
}
-384
View File
@@ -1,384 +0,0 @@
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
import Scheduler from "super-simple-scheduler";
import { Monitor, MonitorType, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { IWorker, WorkerJobSummary } from "@/worker/worker.interface.js";
import { IWorkerHelper } from "@/worker/worker.helper.js";
const SERVICE_NAME = "JobQueue";
export class SuperSimpleQueue implements IWorker {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private helper: IWorkerHelper;
private workerId: string;
private monitorsRepository: IMonitorsRepository;
private readonly scheduler: Scheduler;
constructor(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
this.scheduler = scheduler;
this.registerListeners();
this.workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
}
get serviceName() {
return SuperSimpleQueue.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (error) => {
this.logger.error({
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:abort", (job, reason) => {
this.logger.warn({
message: `${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (job, attempt) => {
this.logger.debug({
message: `${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (job) => {
this.logger.debug({
message: `${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (job, error) => {
this.logger.error({
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (job, error, attempt) => {
this.logger.warn({
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (job) => {
this.logger.debug({
message: `${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
concurrency: 50,
// storeType: "mongo",
// storeType: "redis",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
}
for (const monitor of monitors) {
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor.id, monitor);
}, randomOffset);
}
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 });
return true;
} catch (error: unknown) {
this.logger.error({
message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "init",
});
return false;
}
};
addJob = async (monitorId: string, monitor: Monitor) => {
this.scheduler.addJob({
id: monitorId,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor,
});
// Return early if we don't need geo checks
if (!supportsGeoCheck(monitor.type)) {
return;
}
// Add geo check job if enabled for HTTP monitors
if (monitor.geoCheckEnabled) {
this.scheduler.addJob({
id: `${monitorId}-geo`,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
}
};
deleteJob = async (monitor: Monitor) => {
this.scheduler.removeJob(monitor.id);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: Monitor) => {
const result = await this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to pause monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to pause geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor: Monitor) => {
const result = await this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to resume geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
private syncGeoJob = async (monitor: Monitor) => {
const geoJobId = `${monitor.id}-geo`;
const existingGeoJob = await this.scheduler.getJob(geoJobId);
// If geoChecks have been disabled, or the monitor type doesn't support them, remove
if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) {
if (existingGeoJob) this.scheduler.removeJob(geoJobId);
return;
}
// If the job exists, update it
if (existingGeoJob) {
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
return;
}
// Otherwise, create it
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
};
updateJob = async (monitor: Monitor) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
await this.syncGeoJob(monitor);
};
shutdown = async () => {
this.scheduler.stop();
};
getMetrics = async () => {
const jobs = await this.scheduler.getJobs();
const metrics: {
jobs: number;
activeJobs: number;
failingJobs: number;
jobsWithFailures: Array<{
monitorId: string | number;
monitorUrl: string;
monitorType: MonitorType;
failedAt: number | null;
failCount: number;
failReason: string | null;
}>;
totalRuns: number;
totalFailures: number;
workers: Array<{
workerId: string;
mode: QueueMode;
lastSeenAt: number;
}>;
} = jobs.reduce(
(acc, job) => {
const runCount = job.runCount ?? 0;
const failCount = job.failCount ?? 0;
const lastFailedAt = job.lastFailedAt ?? 0;
const lastRunAt = job.lastRunAt ?? 0;
acc.totalRuns += runCount;
acc.totalFailures += failCount;
acc.jobs++;
if (failCount > 0 && lastFailedAt >= lastRunAt) {
acc.failingJobs++;
}
if (job.lockedAt) {
acc.activeJobs++;
}
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [] as Array<{
monitorId: string | number;
monitorUrl: string;
monitorType: MonitorType;
failedAt: number | null;
failCount: number;
failReason: string | null;
}>,
totalRuns: 0,
totalFailures: 0,
workers: [
{
workerId: this.workerId,
mode: "primary",
lastSeenAt: Date.now(),
},
],
}
);
return metrics;
};
getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: WorkerJobSummary[]; count: number }> => {
const jobs = await this.scheduler.getJobs();
const mapped = jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
monitorGeoInterval: job.data?.geoCheckInterval || null,
monitorActive: job.data?.isActive ?? null,
active: job.active,
repeat: job.repeat ?? null,
lockedBy: job.lockedAt ? this.workerId : null,
lockedUntil: null,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
failReason: job.lastFailReason ?? null,
lastRunAt: job.lastRunAt ?? null,
lastFinishedAt: job.lastFinishedAt ?? null,
lastRunTook: job.lockedAt ? null : (job.lastFinishedAt ?? 0) - (job.lastRunAt ?? 0),
lastFailedAt: job.lastFailedAt ?? null,
};
});
const start = Math.max(page, 0) * rowsPerPage;
return {
jobs: rowsPerPage > 0 ? mapped.slice(start, start + rowsPerPage) : mapped,
count: mapped.length,
};
};
flushQueues = async () => {
const stopRes = await this.scheduler.stop();
const flushRes = await this.scheduler.flushJobs();
const initRes = await this.init();
return {
success: Boolean(stopRes && flushRes && initRes),
};
};
}
@@ -40,6 +40,11 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository {
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
}
async findByIdLean(monitorId: string): Promise<Monitor | null> {
const monitor = this.monitors.find((m) => m.id === monitorId);
return monitor ? { ...monitor } : null;
}
async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise<Monitor[]> {
return this.findByIds(monitorIds);
}
+17 -44
View File
@@ -1,7 +1,7 @@
import { jest } from "@jest/globals";
import { WorkerHelper } from "../../src/worker/worker.helper.ts";
import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts";
import { CheckPipeline, GeoChecksPipeline } from "../../src/worker/worker.check-pipeline.ts";
import { CheckProducer } from "../../src/worker/worker.check-producer.ts";
import { CheckEvaluator } from "../../src/worker/worker.check-evaluator.ts";
import { NotificationReactor } from "../../src/worker/reactors/reactor.notification.ts";
import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
@@ -69,20 +69,6 @@ const createStubMonitorStatsRepo = () => ({
deleteByMonitorIdsNotIn: jest.fn(),
});
const createStubChecksRepo = () => ({
create: jest.fn(),
createChecks: jest.fn(),
findByMonitorId: jest.fn(),
findByTeamId: jest.fn(),
findLatestByMonitorIds: jest.fn(),
findByDateRangeAndMonitorId: jest.fn(),
findSummaryByTeamId: jest.fn(),
deleteByMonitorId: jest.fn(),
deleteByTeamId: jest.fn(),
deleteByMonitorIdsNotIn: jest.fn(),
deleteOlderThan: jest.fn(),
});
export interface HeartbeatTestHarness {
monitorsRepo: InMemoryMonitorsRepository;
incidentsRepo: InMemoryIncidentsRepository;
@@ -106,13 +92,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
const logger = createMockLogger() as any;
const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() };
const statusService = new StatusService(
logger,
bufferStub as any,
monitorsRepo as any,
createStubMonitorStatsRepo() as any,
createStubChecksRepo() as any
);
const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any);
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
@@ -131,7 +111,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
}),
};
const checkService = {
buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
return makeCheck(response.status, response.code);
}),
};
@@ -147,38 +127,31 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
};
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const geoChecksService = { buildGeoCheck: jest.fn() };
const checkPipeline = new CheckPipeline(
const checkProducer = new CheckProducer(
monitorsRepo as any,
maintenanceWindowsRepo as any,
checkService as any,
networkService as any,
bufferStub as any,
new MonitorStatusPolicy(),
statusService as any,
logger
);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepo as any, geoChecksService as any, bufferStub as any, logger);
const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy());
const notificationReactor = new NotificationReactor(notificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
const helper = new WorkerHelper(
logger,
checkService as any,
{ getDBSettings: jest.fn() } as any,
monitorsRepo as any,
{ findAllTeamIds: jest.fn() } as any,
createStubMonitorStatsRepo() as any,
createStubChecksRepo() as any,
incidentsRepo as any,
{ deleteByMonitorIdsNotIn: jest.fn() } as any,
reactorDispatcher,
checkPipeline,
geoCheckPipeline
);
// Mirror the production check→evaluate flow: read the monitor fresh each cycle (so the
// accumulated statusWindow is visible), produce a check, then evaluate against that monitor.
const heartbeatJob = async (seedMonitor: Monitor) => {
const monitor = await monitorsRepo.findByIdLean(seedMonitor.id);
if (!monitor) return;
const result = await checkProducer.produce(monitor);
if (!result) return; // skipped (e.g. maintenance window)
const evaluation = await checkEvaluator.evaluate(result.status, result.check, monitor);
await reactorDispatcher.dispatch(evaluation);
};
return {
monitorsRepo,
@@ -190,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
bufferStub,
maintenanceWindowsRepo,
messageBuilder,
heartbeatJob: helper.getHeartbeatJob(),
heartbeatJob,
setNextResponse,
setNextResponseFull,
};
@@ -0,0 +1,67 @@
import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import { MonitorModel } from "../../src/domain/monitors/monitor.model.ts";
import { backfillMonitorLastEvaluatedAt } from "../../src/db/migration/0008_backfillMonitorLastEvaluatedAt.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
const logger = createMockLogger();
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
// Legacy monitors are inserted through the raw driver, NOT MonitorModel.create — the
// schema default (lastEvaluatedAt: 0) would otherwise stamp the field and the migration's
// { $exists: false } filter would skip it, defeating the test.
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
}, 120_000);
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
beforeEach(async () => {
await MonitorModel.collection.deleteMany({});
});
describe("0008_backfillMonitorLastEvaluatedAt", () => {
it("stamps the upgrade moment (not 0) on monitors missing lastEvaluatedAt", async () => {
await MonitorModel.collection.insertOne({ name: "legacy" }); // pre-upgrade doc, no lastEvaluatedAt
const before = Date.now();
await backfillMonitorLastEvaluatedAt(logger);
const doc = await MonitorModel.collection.findOne({ name: "legacy" });
// Must be a recent timestamp — backfilling 0 would replay the whole check history on the first evaluate.
expect(doc?.lastEvaluatedAt).not.toBe(0);
expect(doc?.lastEvaluatedAt).toBeGreaterThanOrEqual(before);
expect(doc?.lastEvaluatedAt).toBeLessThanOrEqual(Date.now());
});
it("leaves monitors that already have lastEvaluatedAt untouched (idempotent)", async () => {
await MonitorModel.collection.insertOne({ name: "current", lastEvaluatedAt: 12345 });
await backfillMonitorLastEvaluatedAt(logger);
const doc = await MonitorModel.collection.findOne({ name: "current" });
expect(doc?.lastEvaluatedAt).toBe(12345);
});
it("drops the legacy jobs collection left behind by the old scheduler", async () => {
const db = mongoose.connection.db!;
await db.collection("jobs").insertOne({ id: "legacy-job", template: "monitor-job" }); // old-scheduler row
await backfillMonitorLastEvaluatedAt(logger);
const exists = await db.listCollections({ name: "jobs" }).toArray();
expect(exists).toHaveLength(0);
});
it("does not throw when there is no jobs collection to drop", async () => {
await expect(backfillMonitorLastEvaluatedAt(logger)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,500 @@
import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import MongoJobsRepository from "../../src/domain/jobs/job.repository.mongo.ts";
import JobModel, { type JobDocument } from "../../src/domain/jobs/job.model.ts";
import { BACKOFF_MS, LOCK_MS, type Job, type JobType } from "../../src/domain/jobs/job.type.ts";
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
// The headline guarantee of the queue — "exactly one claimer wins" — IS MongoDB's
// single-document atomicity on findOneAndUpdate, so it can only be exercised against a
// live engine. We boot a throwaway in-process mongod for this suite.
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
await JobModel.init(); // build the indexes the claim scan relies on
}, 120_000);
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
beforeEach(async () => {
await JobModel.deleteMany({});
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const NOW = 1_000_000;
// Insert a job row directly, full control over every field. Defaults describe a due,
// unlocked, active check job.
const seedJob = (overrides: Partial<JobDocument> = {}): Promise<JobDocument> =>
JobModel.create({
_id: overrides._id ?? `check:${overrides.refId ?? "mon-1"}`,
type: "check" as JobType,
refId: "mon-1",
isActive: true,
nextScheduledAt: NOW,
intervalMs: 60_000,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
...overrides,
});
const readRow = (id: string) => JobModel.findById(id).lean<JobDocument>();
// record*/renewLocks are fenced on the repo's private workerId, so a pre-seeded "held"
// lock must be stamped with the same id the repo will check against.
const ownedBy = (r: MongoJobsRepository) => (r as unknown as { workerId: string }).workerId;
const WORKER_ID = "worker-under-test";
describe("MongoJobsRepository", () => {
let repo: MongoJobsRepository;
beforeEach(() => {
repo = new MongoJobsRepository(WORKER_ID);
});
// ── claimDue ───────────────────────────────────────────────────────────────
describe("claimDue", () => {
it("claims a due, unlocked job and leases it", async () => {
await seedJob();
const claimed = await repo.claimDue("check", NOW);
expect(claimed?.id).toBe("check:mon-1");
const row = await readRow("check:mon-1");
expect(row?.lockedBy).not.toBeNull();
expect(row?.lockedUntil).toBe(NOW + LOCK_MS);
});
it("returns null when nothing is due yet", async () => {
await seedJob({ nextScheduledAt: NOW + 1 });
expect(await repo.claimDue("check", NOW)).toBeNull();
});
it("skips inactive (paused) jobs", async () => {
await seedJob({ isActive: false });
expect(await repo.claimDue("check", NOW)).toBeNull();
});
it("only claims the requested type", async () => {
await seedJob({ _id: "geo:mon-1", type: "geo-check" });
expect(await repo.claimDue("check", NOW)).toBeNull();
});
it("does not claim a job held by a live lease", async () => {
await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS });
expect(await repo.claimDue("check", NOW)).toBeNull();
});
it("reclaims a job whose lease has expired (crash recovery)", async () => {
await seedJob({ lockedBy: "dead-worker", lockedUntil: NOW - 1 });
const claimed = await repo.claimDue("check", NOW);
expect(claimed?.id).toBe("check:mon-1");
const row = await readRow("check:mon-1");
expect(row?.lockedBy).not.toBe("dead-worker");
expect(row?.lockedUntil).toBe(NOW + LOCK_MS);
});
it("returns the oldest-due job first", async () => {
await seedJob({ _id: "check:newer", refId: "newer", nextScheduledAt: NOW });
await seedJob({ _id: "check:older", refId: "older", nextScheduledAt: NOW - 5_000 });
const claimed = await repo.claimDue("check", NOW);
expect(claimed?.id).toBe("check:older");
});
it("lets exactly one of many concurrent claimers win a single due job", async () => {
await seedJob();
// Distinct repo instances = distinct workerIds = realistic competing workers.
const workers = Array.from({ length: 20 }, (_, i) => new MongoJobsRepository(`worker-${i}`));
const results = await Promise.all(workers.map((w) => w.claimDue("check", NOW)));
const winners = results.filter((r): r is Job => r !== null);
expect(winners).toHaveLength(1);
expect(winners[0].id).toBe("check:mon-1");
});
it("hands two concurrent claimers two distinct due jobs (no double-claim)", async () => {
await seedJob({ _id: "check:a", refId: "a" });
await seedJob({ _id: "check:b", refId: "b" });
const [w1, w2] = [new MongoJobsRepository("worker-1"), new MongoJobsRepository("worker-2")];
const [r1, r2] = await Promise.all([w1.claimDue("check", NOW), w2.claimDue("check", NOW)]);
expect(r1).not.toBeNull();
expect(r2).not.toBeNull();
expect(r1?.id).not.toBe(r2?.id);
expect([r1?.id, r2?.id].sort()).toEqual(["check:a", "check:b"]);
});
});
// ── recordSuccess (fixed-rate reschedule) ────────────────────────────────────
describe("recordSuccess", () => {
it("reschedules fixed-rate from the scheduled tick and releases the lease", async () => {
await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS, runCount: 4 });
const ok = await repo.recordSuccess("check:mon-1", NOW, 60_000, NOW + 1_500);
expect(ok).toBe(true);
const row = await readRow("check:mon-1");
// next tick measured from the scheduled time (NOW), not completion time
expect(row?.nextScheduledAt).toBe(NOW + 60_000);
expect(row?.lockedBy).toBeNull();
expect(row?.lockedUntil).toBeNull();
expect(row?.lastFinishedAt).toBe(NOW + 1_500);
expect(row?.runCount).toBe(5);
});
it("skips missed ticks when the worker has fallen behind, with jitter to avoid a herd", async () => {
await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS });
// scheduled NOW, interval 60s, but completion is 200s late → NOW+60s is already past
const now = NOW + 200_000;
await repo.recordSuccess("check:mon-1", NOW, 60_000, now);
const row = await readRow("check:mon-1");
// no catch-up burst: rebased to one interval from now, plus up to one interval of jitter
// so behind-jobs don't re-synchronize. Never earlier than now + interval (no over-checking).
expect(row?.nextScheduledAt).toBeGreaterThanOrEqual(now + 60_000);
expect(row?.nextScheduledAt).toBeLessThan(now + 120_000);
});
it("returns false for an unknown id", async () => {
expect(await repo.recordSuccess("missing", NOW, 60_000, NOW)).toBe(false);
});
it("does not release a lock owned by another worker (fenced no-op)", async () => {
// A worker whose lease expired and was reclaimed must not clobber the new owner's lock.
await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS, runCount: 7 });
const ok = await repo.recordSuccess("check:mon-1", NOW, 60_000, NOW + 1_000);
expect(ok).toBe(false);
const row = await readRow("check:mon-1");
expect(row?.lockedBy).toBe("other-worker"); // lock untouched
expect(row?.lockedUntil).toBe(NOW + LOCK_MS);
expect(row?.runCount).toBe(7); // not double-counted
});
});
// ── renewLocks (lease renewal) ───────────────────────────────────────────────
describe("renewLocks", () => {
it("extends the lease on a lock this worker holds", async () => {
await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS });
const renewed = await repo.renewLocks(["check:mon-1"], NOW + 1_000);
expect(renewed).toBe(1);
expect((await readRow("check:mon-1"))?.lockedUntil).toBe(NOW + 1_000 + LOCK_MS);
});
it("does not renew a lock held by another worker (fenced)", async () => {
await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS });
const renewed = await repo.renewLocks(["check:mon-1"], NOW + 1_000);
expect(renewed).toBe(0);
expect((await readRow("check:mon-1"))?.lockedUntil).toBe(NOW + LOCK_MS); // unchanged
});
it("renews only the locks this worker holds out of a mixed batch", async () => {
await seedJob({ _id: "check:mine", refId: "mine", lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS });
await seedJob({ _id: "check:theirs", refId: "theirs", lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS });
const renewed = await repo.renewLocks(["check:mine", "check:theirs"], NOW + 1_000);
expect(renewed).toBe(1);
expect((await readRow("check:mine"))?.lockedUntil).toBe(NOW + 1_000 + LOCK_MS);
expect((await readRow("check:theirs"))?.lockedUntil).toBe(NOW + LOCK_MS);
});
it("returns 0 for an empty id list without touching the db", async () => {
expect(await repo.renewLocks([], NOW)).toBe(0);
});
// Regression: a renewed lock keeps a slow-but-alive job from being reclaimed once its
// original lease would have expired — preventing the duplicate-execution path.
it("keeps a job from being reclaimed after the original lease window passes", async () => {
await seedJob();
await repo.claimDue("check", NOW); // this worker now holds the lease until NOW + LOCK_MS
// just before expiry, the still-running job renews its own lock
const renewAt = NOW + LOCK_MS - 1_000;
expect(await repo.renewLocks(["check:mon-1"], renewAt)).toBe(1);
// a second worker scans PAST the original lease end — without renewal it would reclaim
const other = new MongoJobsRepository("competing-worker");
expect(await other.claimDue("check", NOW + LOCK_MS + 1)).toBeNull();
const row = await readRow("check:mon-1");
expect(row?.lockedBy).toBe(ownedBy(repo)); // still held by the original worker
expect(row?.lockedUntil).toBe(renewAt + LOCK_MS);
});
});
// ── recordFailure (backoff) ──────────────────────────────────────────────────
describe("recordFailure", () => {
it("backs off, records the reason, bumps failCount, releases the lease", async () => {
await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS, failCount: 2 });
const ok = await repo.recordFailure("check:mon-1", new Error("connection refused"), NOW);
expect(ok).toBe(true);
const row = await readRow("check:mon-1");
expect(row?.nextScheduledAt).toBe(NOW + BACKOFF_MS);
expect(row?.lastFailReason).toBe("connection refused");
expect(row?.lastFinishedAt).toBe(NOW);
expect(row?.failCount).toBe(3);
expect(row?.lockedBy).toBeNull();
expect(row?.lockedUntil).toBeNull();
});
it("stringifies a non-Error reason", async () => {
await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS });
await repo.recordFailure("check:mon-1", "weird non-error", NOW);
const row = await readRow("check:mon-1");
expect(row?.lastFailReason).toBe("weird non-error");
});
it("returns false for an unknown id", async () => {
expect(await repo.recordFailure("missing", new Error("x"), NOW)).toBe(false);
});
});
// ── upsertEvaluate (handoff + $min coalescing) ───────────────────────────────
describe("upsertEvaluate", () => {
it("creates a single evaluate row keyed by monitor", async () => {
await repo.upsertEvaluate("mon-1", NOW);
const row = await readRow("evaluate:mon-1");
expect(row?.type).toBe("evaluate");
expect(row?.refId).toBe("mon-1");
expect(row?.nextScheduledAt).toBe(NOW);
expect(row?.intervalMs).toBeNull();
expect(row?.isActive).toBe(true);
});
it("coalesces rapid checks: a later call never pushes runAt out ($min)", async () => {
await repo.upsertEvaluate("mon-1", NOW);
await repo.upsertEvaluate("mon-1", NOW + 9_000);
const rows = await JobModel.find({ type: "evaluate", refId: "mon-1" }).lean<JobDocument[]>();
expect(rows).toHaveLength(1);
expect(rows[0].nextScheduledAt).toBe(NOW); // earliest pending wins
});
it("lowers runAt when an earlier evaluation arrives ($min)", async () => {
await repo.upsertEvaluate("mon-1", NOW + 9_000);
await repo.upsertEvaluate("mon-1", NOW);
const row = await readRow("evaluate:mon-1");
expect(row?.nextScheduledAt).toBe(NOW);
});
it("concurrent upserts for one monitor produce exactly one row", async () => {
const workers = Array.from({ length: 20 }, (_, i) => new MongoJobsRepository(`worker-${i}`));
await Promise.all(workers.map((w) => w.upsertEvaluate("mon-1", NOW)));
const count = await JobModel.countDocuments({ type: "evaluate", refId: "mon-1" });
expect(count).toBe(1);
});
});
// ── monitor lifecycle CRUD ───────────────────────────────────────────────────
describe("CRUD", () => {
it("upsertJob inserts, then refreshes config without clobbering scheduling state", async () => {
const job: Job = {
id: "check:mon-1",
type: "check",
refId: "mon-1",
isActive: true,
nextScheduledAt: NOW,
intervalMs: 60_000,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
};
await repo.upsertJob(job);
// simulate a running job that has advanced + holds a lease
await JobModel.updateOne({ _id: "check:mon-1" }, { $set: { nextScheduledAt: NOW + 5_000, lockedBy: "live", runCount: 7 } });
// a reconcile sweep re-upserts the same id with new config
await repo.upsertJob({ ...job, intervalMs: 30_000, isActive: false });
const row = await readRow("check:mon-1");
expect(row?.intervalMs).toBe(30_000); // config refreshed ($set)
expect(row?.isActive).toBe(false);
expect(row?.nextScheduledAt).toBe(NOW + 5_000); // scheduling untouched ($setOnInsert)
expect(row?.lockedBy).toBe("live");
expect(row?.runCount).toBe(7);
});
it("upsertCleanupJob resets nextScheduledAt on an existing row so it is due again on restart", async () => {
const job: Job = {
id: "cleanup-orphaned",
type: "cleanup-orphaned",
refId: null,
isActive: true,
nextScheduledAt: NOW,
intervalMs: 86_400_000,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
};
await repo.upsertCleanupJob(job);
// row has already advanced a day into the future after a previous run
await JobModel.updateOne({ _id: "cleanup-orphaned" }, { $set: { nextScheduledAt: NOW + 86_400_000, runCount: 3 } });
// a restart reconcile re-upserts and pulls it due now
await repo.upsertCleanupJob({ ...job, nextScheduledAt: NOW + 1_000 });
const row = await readRow("cleanup-orphaned");
expect(row?.nextScheduledAt).toBe(NOW + 1_000); // schedule reset ($set)
expect(row?.runCount).toBe(3); // run history untouched ($setOnInsert)
});
it("setActiveById flips check and geo rows together", async () => {
await seedJob({ _id: "check:mon-1", type: "check" });
await seedJob({ _id: "geo:mon-1", type: "geo-check" });
await repo.setActiveById("mon-1", false);
const rows = await JobModel.find({ refId: "mon-1" }).lean<JobDocument[]>();
expect(rows).toHaveLength(2);
expect(rows.every((r) => r.isActive === false)).toBe(true);
});
it("updateScheduleById changes the interval on a single typed row", async () => {
await seedJob({ _id: "check:mon-1", type: "check", intervalMs: 60_000 });
await seedJob({ _id: "geo:mon-1", type: "geo-check", intervalMs: 60_000 });
await repo.updateScheduleById("mon-1", "check", 15_000);
expect((await readRow("check:mon-1"))?.intervalMs).toBe(15_000);
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" });
await repo.upsertEvaluate("mon-1", NOW);
await repo.deleteById("mon-1");
expect(await JobModel.countDocuments({ refId: "mon-1" })).toBe(0);
});
it("deleteByMonitorIdsNotIn drops jobs for missing monitors but keeps valid and global rows", async () => {
await seedJob({ _id: "check:keep", refId: "keep", type: "check" });
await seedJob({ _id: "geo:keep", refId: "keep", type: "geo-check" });
await seedJob({ _id: "check:gone", refId: "gone", type: "check" });
await seedJob({ _id: "cleanup-orphaned", refId: null, type: "cleanup-orphaned" });
await seedJob({ _id: "cleanup-retention", refId: null, type: "cleanup-retention" });
const deleted = await repo.deleteByMonitorIdsNotIn(["keep"]);
expect(deleted).toBe(1); // only check:gone
const remaining = (await JobModel.find().lean<JobDocument[]>()).map((d) => d._id).sort();
expect(remaining).toEqual(["check:keep", "cleanup-orphaned", "cleanup-retention", "geo:keep"]);
});
it("findById returns all of a monitor's rows", async () => {
await seedJob({ _id: "check:mon-1", type: "check" });
await seedJob({ _id: "geo:mon-1", type: "geo-check" });
const jobs = await repo.findById("mon-1");
expect(jobs.map((j) => j.id).sort()).toEqual(["check:mon-1", "geo:mon-1"]);
});
});
// ── observability ────────────────────────────────────────────────────────────
describe("observability", () => {
it("findPage returns a page plus the total count", async () => {
await seedJob({ _id: "check:a", refId: "a", nextScheduledAt: NOW + 1 });
await seedJob({ _id: "check:b", refId: "b", nextScheduledAt: NOW + 2 });
await seedJob({ _id: "check:c", refId: "c", nextScheduledAt: NOW + 3 });
const { jobs, count } = await repo.findPage({ page: 0, rowsPerPage: 2 });
expect(count).toBe(3);
expect(jobs).toHaveLength(2);
expect(jobs.map((j) => j.id)).toEqual(["check:a", "check:b"]); // sorted by nextScheduledAt
});
it("findPage returns everything when rowsPerPage is 0", async () => {
await seedJob({ _id: "check:a", refId: "a" });
await seedJob({ _id: "check:b", refId: "b" });
const { jobs, count } = await repo.findPage({});
expect(count).toBe(2);
expect(jobs).toHaveLength(2);
});
it("findAll returns every row", async () => {
await seedJob({ _id: "check:a", refId: "a" });
await seedJob({ _id: "geo:b", refId: "b", type: "geo-check" });
expect(await repo.findAll()).toHaveLength(2);
});
});
});
@@ -3,6 +3,9 @@ import type { NextFunction, Request, Response } from "express";
import { MulterError } from "multer";
import { handleErrors } from "../../../src/api/middleware/handleErrors.ts";
import { AppError } from "../../../src/utils/AppError.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const logger = createMockLogger();
const makeRes = (): Response => {
const res = {} as Response;
@@ -14,25 +17,25 @@ const makeRes = (): Response => {
describe("handleErrors", () => {
it("maps a multer file-size violation to 413 Payload Too Large", () => {
const res = makeRes();
handleErrors(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
handleErrors(logger)(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(413);
});
it("maps other multer errors to 400", () => {
const res = makeRes();
handleErrors(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
handleErrors(logger)(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(400);
});
it("preserves AppError status codes", () => {
const res = makeRes();
handleErrors(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction);
handleErrors(logger)(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(415);
});
it("falls back to 500 for unknown errors", () => {
const res = makeRes();
handleErrors(new Error("boom"), {} as Request, res, (() => {}) as NextFunction);
handleErrors(logger)(new Error("boom"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(500);
});
});
@@ -433,4 +433,65 @@ describe("HttpProvider", () => {
expect(result.extracted).toBe("down");
});
});
// ── HTTP method (GET / HEAD) ─────────────────────────────────────────
describe("request method", () => {
it("passes GET to got when method is GET", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ method: "GET" }));
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" }));
});
it("passes HEAD to got when method is HEAD", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "" }));
const { provider } = createProvider();
await provider.handle(makeMonitor({ method: "HEAD" }));
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" }));
});
it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} }));
// A matcher that would fail — proving HEAD never reaches it
const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" });
const { provider, advancedMatcher } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" }));
expect(result.status).toBe(true);
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
it("bases HEAD status on the status code (down when not an up code)", async () => {
const err = new HTTPError("Internal Server Error");
(err as any).response = { statusCode: 500, body: "", headers: {} };
(err as any).timings = { phases: { total: 40 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ method: "HEAD" }));
expect(result.status).toBe(false);
expect(result.code).toBe(500);
});
it("respects customUpCodes for HEAD requests without consulting the matcher", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401, body: "", headers: {} };
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const { provider, advancedMatcher } = createProvider();
const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true }));
expect(result.status).toBe(true);
expect(result.code).toBe(401);
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
});
});
@@ -4,6 +4,7 @@ import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { ICheckService } from "../../../src/domain/checks/check.service.ts";
import type { IGeoChecksService } from "../../../src/domain/geo-checks/geo-check.service.ts";
import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts";
import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts";
import type { Check } from "../../../src/domain/checks/check.type.ts";
import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts";
@@ -24,6 +25,11 @@ const createMockSettingsService = (nodeEnv: string = "development") =>
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
}) as unknown as jest.Mocked<ISettingsService>;
const createMockJobsRepository = () =>
({
upsertEvaluate: jest.fn().mockResolvedValue(true),
}) as unknown as jest.Mocked<IJobsRepository>;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
@@ -47,8 +53,9 @@ const createService = (nodeEnv: string = "development") => {
const checkService = createMockCheckService();
const geoChecksService = createMockGeoChecksService();
const settingsService = createMockSettingsService(nodeEnv);
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService);
return { service, logger, checkService, geoChecksService };
const jobsRepository = createMockJobsRepository();
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository);
return { service, logger, checkService, geoChecksService, jobsRepository };
};
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -344,6 +351,29 @@ describe("BufferService", () => {
);
});
it("arms the evaluate stage once per distinct monitor after the checks are stored", async () => {
const { service, jobsRepository } = createService();
service.addToBuffer(makeCheck({ id: "c1", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } }));
service.addToBuffer(makeCheck({ id: "c2", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } }));
service.addToBuffer(makeCheck({ id: "c3", metadata: { monitorId: "mon-2", teamId: "team-1", type: "http" } }));
await service.flushBuffer();
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2);
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-1", expect.any(Number));
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-2", expect.any(Number));
});
it("does not arm the evaluate stage when the check write fails", async () => {
const { service, checkService, jobsRepository } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(jobsRepository.upsertEvaluate).not.toHaveBeenCalled();
});
it("clears buffer even on error to prevent infinite retries", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
@@ -0,0 +1,87 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckEvaluator } from "../../../src/worker/worker.check-evaluator.ts";
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
...overrides,
}) as Monitor;
const createEvaluator = (overrides?: Record<string, any>) => {
const defaults = {
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: makeMonitor({ status: "up" }), statusChanged: false, prevStatus: "up", code: 200 }),
},
monitorStatusPolicy: new MonitorStatusPolicy(),
...overrides,
};
const evaluator = new CheckEvaluator(defaults.statusService as any, defaults.monitorStatusPolicy as any);
return { evaluator, defaults };
};
describe("CheckEvaluator", () => {
it("threads status, check, and monitor through to the status service", async () => {
const { evaluator, defaults } = createEvaluator();
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
const check = { id: "check-1" } as any;
const monitor = makeMonitor({ status: "up" });
await evaluator.evaluate(status, check, monitor);
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalledWith(status, check, monitor);
});
it("returns an evaluation that opens an incident on a down transition", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any;
const check = { id: "check-1" } as any;
const statusChange = { monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up", code: 500 };
const { evaluator } = createEvaluator({
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
});
const result = await evaluator.evaluate(status, check, makeMonitor());
expect(result).toMatchObject({
monitor: statusChange.monitor,
status,
check,
statusChange,
decision: expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
}),
});
});
it("returns an evaluation that resolves an incident on recovery", async () => {
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
const statusChange = { monitor: makeMonitor({ status: "up" }), statusChanged: true, prevStatus: "down", code: 200 };
const { evaluator } = createEvaluator({
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
});
const result = await evaluator.evaluate(status, { id: "check-1" } as any, makeMonitor({ status: "down" }));
expect(result.decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true });
});
it("produces a no-op decision when status did not change", async () => {
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
const { evaluator } = createEvaluator();
const result = await evaluator.evaluate(status, { id: "check-1" } as any, makeMonitor());
expect(result.decision).toMatchObject({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
});
});
});
+1 -110
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckPipeline, GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts";
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
import { GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
@@ -21,114 +20,6 @@ const activeWindow = () => {
return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 };
};
// ── CheckPipeline ────────────────────────────────────────────────────────────
const createCheckPipeline = (overrides?: Record<string, any>) => {
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
},
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
buffer: { addToBuffer: jest.fn() },
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
monitorStatusPolicy: new MonitorStatusPolicy(),
...overrides,
};
const pipeline = new CheckPipeline(
defaults.monitorsRepository as any,
defaults.maintenanceWindowsRepository as any,
defaults.checkService as any,
defaults.networkService as any,
defaults.buffer as any,
defaults.monitorStatusPolicy as any,
defaults.statusService as any,
defaults.logger as any
);
return { pipeline, defaults };
};
describe("CheckPipeline", () => {
it("throws when monitor id is missing", async () => {
const { pipeline } = createCheckPipeline();
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
});
it("skips and sets maintenance status when in maintenance window", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
const result = await pipeline.run(makeMonitor({ status: "up" }));
expect(result).toBeNull();
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
});
it("does not re-write status when already in maintenance", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
await pipeline.run(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("throws when network response is null", async () => {
const { pipeline } = createCheckPipeline({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response");
});
it("returns null and warns when buildCheck returns null", async () => {
const { pipeline, defaults } = createCheckPipeline({
checkService: { buildCheck: jest.fn().mockReturnValue(null) },
});
const result = await pipeline.run(makeMonitor());
expect(result).toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("buffers the check, updates status, and returns an evaluation", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { pipeline, defaults } = createCheckPipeline({
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
});
const result = await pipeline.run(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(result).toMatchObject({
monitor: statusChange.monitor,
status,
check: { id: "check-1" },
statusChange,
decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }),
});
});
it("proceeds with the check when no maintenance window is active", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) },
});
await pipeline.run(makeMonitor());
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
});
});
// ── GeoChecksPipeline ──────────────────────────────────────────────────────────
const createGeoPipeline = (overrides?: Record<string, any>) => {
@@ -0,0 +1,120 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckProducer } from "../../../src/worker/worker.check-producer.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
...overrides,
}) as Monitor;
// A maintenance window straddling "now" — active per isWindowActive.
const activeWindow = () => {
const now = Date.now();
return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 };
};
const createProducer = (overrides?: Record<string, any>) => {
const defaults = {
logger: createMockLogger(),
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
buffer: { addToBuffer: jest.fn() },
...overrides,
};
const producer = new CheckProducer(
defaults.monitorsRepository as any,
defaults.maintenanceWindowsRepository as any,
defaults.checkService as any,
defaults.networkService as any,
defaults.buffer as any,
defaults.logger as any
);
return { producer, defaults };
};
describe("CheckProducer", () => {
it("throws when monitor id is missing", async () => {
const { producer } = createProducer();
await expect(producer.produce({} as Monitor)).rejects.toThrow("No monitor id");
});
// ── maintenance gate ──────────────────────────────────────────────────────
it("skips the check and flips status to 'maintenance' when in a maintenance window", async () => {
const { producer, defaults } = createProducer({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
const result = await producer.produce(makeMonitor({ status: "up" }));
expect(result).toBeNull();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] });
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.buffer.addToBuffer).not.toHaveBeenCalled();
});
it("does not re-write status when already in maintenance", async () => {
const { producer, defaults } = createProducer({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
const result = await producer.produce(makeMonitor({ status: "maintenance" }));
expect(result).toBeNull();
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("proceeds with the check when the maintenance window is inactive", async () => {
const { producer, defaults } = createProducer({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) },
});
await producer.produce(makeMonitor());
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
});
// ── acquire / record ──────────────────────────────────────────────────────
it("throws when the network response is null", async () => {
const { producer } = createProducer({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
await expect(producer.produce(makeMonitor())).rejects.toThrow("No network response");
});
it("returns null, warns, and does not buffer when toCheck yields nothing", async () => {
const { producer, defaults } = createProducer({
checkService: { toCheck: jest.fn().mockReturnValue(null) },
});
const result = await producer.produce(makeMonitor());
expect(result).toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.buffer.addToBuffer).not.toHaveBeenCalled();
});
it("buffers the built check and returns the status and check on success", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
const check = { id: "check-1" };
const { producer, defaults } = createProducer({
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
checkService: { toCheck: jest.fn().mockReturnValue(check) },
});
const result = await producer.produce(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check);
expect(result).toEqual({ status, check });
});
});
+96 -16
View File
@@ -4,6 +4,8 @@ import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/network.ts";
import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -53,12 +55,12 @@ describe("CheckService", () => {
});
});
// ── buildCheck ───────────────────────────────────────────────────────────
// ── toCheck ──────────────────────────────────────────────────────────────
describe("buildCheck", () => {
describe("toCheck", () => {
it("builds a basic HTTP check with correct fields", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse());
const check = service.toCheck(makeStatusResponse());
expect(check).toBeDefined();
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
@@ -72,14 +74,14 @@ describe("CheckService", () => {
it("defaults responseTime to 0 when falsy", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
const check = service.toCheck(makeStatusResponse({ responseTime: undefined }));
expect(check!.responseTime).toBe(0);
});
it("includes timings when provided", () => {
const timings = { start: 0, socket: 10, lookup: 20 };
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ timings } as any));
const check = service.toCheck(makeStatusResponse({ timings } as any));
expect(check!.timings).toBe(timings);
});
@@ -106,7 +108,7 @@ describe("CheckService", () => {
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.accessibility).toBe(90);
expect(check!.bestPractices).toBe(80);
@@ -119,7 +121,7 @@ describe("CheckService", () => {
it("returns undefined when pagespeed payload is missing", () => {
const { service, logger } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
expect(check).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
@@ -127,7 +129,7 @@ describe("CheckService", () => {
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
expect(check!.accessibility).toBe(0);
expect(check!.bestPractices).toBe(0);
@@ -146,7 +148,7 @@ describe("CheckService", () => {
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBe("informative");
});
@@ -161,7 +163,7 @@ describe("CheckService", () => {
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBeNull();
});
@@ -176,7 +178,7 @@ describe("CheckService", () => {
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.si!.numericValue).toBeUndefined();
});
@@ -192,7 +194,7 @@ describe("CheckService", () => {
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls).toBeUndefined();
expect(check!.audits!.si).toBeUndefined();
@@ -214,7 +216,7 @@ describe("CheckService", () => {
capture: { screenshot: "base64data" },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
expect(check!.memory).toEqual({ usage_percent: 0.6 });
@@ -230,7 +232,7 @@ describe("CheckService", () => {
errors: [{ message: "timeout" }],
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "timeout" }]);
});
@@ -241,14 +243,14 @@ describe("CheckService", () => {
errors: { errors: [{ message: "nested error" }] },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "nested error" }]);
});
it("handles undefined payload gracefully", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
const check = service.toCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
expect(check).toBeDefined();
expect(check!.cpu).toBeUndefined();
@@ -259,6 +261,84 @@ describe("CheckService", () => {
});
});
// ── toStatusResponse round-trip (Phase 2 §5 guard) ─────────────────────────
// The DB-queue split persists a Check, then the evaluator rebuilds the status response from it.
// toStatusResponse is only correct because every field a downstream consumer reads survives
// toCheck → toStatusResponse. These tests pin that: if a future change drops a consumed field
// from the round-trip, they fail instead of silently regressing the evaluator/reactors.
describe("toStatusResponse round-trip", () => {
const makeHardwareStatus = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
makeStatusResponse({
type: "hardware",
status: true,
code: 200,
message: "OK",
responseTime: 42,
payload: {
data: {
cpu: { usage_percent: 0.92 },
memory: { usage_percent: 0.81 },
disk: [{ device: "/dev/sda", usage_percent: 0.75 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
} as HardwareStatusPayload,
...overrides,
} as any);
it("uptime: preserves every consumed scalar field", () => {
const { service } = createService();
const status = makeStatusResponse({ type: "http", status: false, code: 503, message: "Service Unavailable", responseTime: 87 });
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
expect(rebuilt.monitorId).toBe(status.monitorId);
expect(rebuilt.teamId).toBe(status.teamId);
expect(rebuilt.type).toBe(status.type);
expect(rebuilt.status).toBe(status.status);
expect(rebuilt.code).toBe(status.code);
expect(rebuilt.message).toBe(status.message);
expect(rebuilt.responseTime).toBe(status.responseTime);
});
it("hardware: preserves payload.data metrics verbatim, same units (usage_percent stays a 0–1 decimal)", () => {
const { service } = createService();
const status = makeHardwareStatus();
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
const original = status.payload as HardwareStatusPayload;
const roundtripped = rebuilt.payload as HardwareStatusPayload;
expect(roundtripped.data).toEqual(original.data);
expect(roundtripped.data.cpu!.usage_percent).toBe(0.92); // not rescaled to 92
});
it("hardware: a real consumer (extractThresholdBreaches) behaves identically on raw vs round-tripped status", () => {
const { service } = createService();
const builder = new NotificationMessageBuilder();
const monitor = {
id: "mon-1",
type: "hardware",
cpuAlertThreshold: 90, // 0.92 → 92% breaches
memoryAlertThreshold: 90, // 0.81 → 81% does not
diskAlertThreshold: 90, // 0.75 → 75% does not
tempAlertThreshold: 100,
} as Monitor;
const status = makeHardwareStatus();
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
const fromRaw = builder.extractThresholdBreaches(monitor, status as MonitorStatusResponse<HardwareStatusPayload>);
const fromRebuilt = builder.extractThresholdBreaches(monitor, rebuilt as MonitorStatusResponse<HardwareStatusPayload>);
// sanity: the fixture actually triggers a breach, so the equality below is not vacuous
expect(fromRaw).toHaveLength(1);
expect(fromRaw[0].metric).toBe("cpu");
expect(fromRebuilt).toEqual(fromRaw);
});
});
// ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => {
@@ -0,0 +1,698 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { DBQueueWorker } from "../../../src/worker/worker.db-queue.ts";
import { LOCK_MS } from "../../../src/domain/jobs/job.type.ts";
import type { Job } from "../../../src/domain/jobs/job.type.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import type { QueueMode } from "../../../src/domain/app-settings/app-settings.type.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// ── Notes ──────────────────────────────────────────────────────────────────────
//
// DBQueueWorker is the orchestrator: it drives the polling loops, claims batches
// sized to free capacity, runs jobs (check / evaluate / geo-check / cleanup) and
// renews their locks, and registers itself in the worker heartbeat. The repository
// claim/lock atomicity is covered in integration/jobsRepository.test.ts; here we
// test only the orchestration around it, with the repository fully mocked.
//
// Fake timers are mandatory: init() starts self-re-arming setTimeout loops and a
// heartbeat setInterval, so without them the loops would leak across tests. The
// loop re-arms at POLL_MS (250ms) and the lock renews at LOCK_MS / 3 (20s).
const POLL_MS = 250;
const LOCK_RENEW_MS = LOCK_MS / 3;
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
isActive: true,
geoCheckEnabled: false,
geoCheckInterval: 300000,
lastEvaluatedAt: 0,
...overrides,
}) as Monitor;
const makeJob = (overrides?: Partial<Job>): Job => ({
id: "check:m1",
type: "check",
refId: "m1",
isActive: true,
nextScheduledAt: 1000,
intervalMs: 60000,
lockedBy: null,
lockedUntil: null,
runCount: 0,
failCount: 0,
lastFinishedAt: null,
lastFailReason: null,
...overrides,
});
const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses?: boolean; mocks?: Record<string, any> }) => {
const jobsRepository = {
claimDueBatch: jest.fn<any>().mockResolvedValue([]),
upsertJob: jest.fn<any>().mockResolvedValue(true),
upsertCleanupJob: jest.fn<any>().mockResolvedValue(true),
upsertEvaluate: jest.fn<any>().mockResolvedValue(true),
recordSuccess: jest.fn<any>().mockResolvedValue(true),
recordFailure: jest.fn<any>().mockResolvedValue(true),
recordOneShot: jest.fn<any>().mockResolvedValue(true),
renewLocks: jest.fn<any>().mockResolvedValue(1),
deleteById: jest.fn<any>().mockResolvedValue(true),
deleteByIdAndType: jest.fn<any>().mockResolvedValue(true),
setActiveById: jest.fn<any>().mockResolvedValue(true),
updateScheduleById: jest.fn<any>().mockResolvedValue(true),
findAll: jest.fn<any>().mockResolvedValue([]),
findPage: jest.fn<any>().mockResolvedValue({ jobs: [], count: 0 }),
};
const monitorsRepository = {
findByIds: jest.fn<any>().mockResolvedValue([makeMonitor()]),
findByIdLean: jest.fn<any>().mockResolvedValue(makeMonitor()),
findAll: jest.fn<any>().mockResolvedValue([]),
updateById: jest.fn<any>().mockResolvedValue({}),
};
const checksRepository = {
findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([]),
};
const checkService = {
toStatusResponse: jest.fn<any>().mockReturnValue({ status: "up" }),
toLastEvaluatedAt: jest.fn<any>().mockReturnValue(12345),
};
const checkProducer = {
produce: jest.fn<any>().mockResolvedValue({ status: { status: "up" }, check: { id: "c1" } }),
};
const checkEvaluator = {
evaluate: jest.fn<any>().mockResolvedValue({ monitor: makeMonitor(), statusChange: { monitor: makeMonitor() }, decision: {} }),
};
const geoCheckPipeline = { run: jest.fn<any>().mockResolvedValue(null) };
const dispatcher = { dispatch: jest.fn<any>().mockResolvedValue(undefined) };
const helper = {
getCleanupOrphanedJob: jest.fn<any>().mockReturnValue(jest.fn<any>().mockResolvedValue(undefined)),
getCleanupRetentionJob: jest.fn<any>().mockReturnValue(jest.fn<any>().mockResolvedValue(undefined)),
};
const queueWorkersRepository = {
upsert: jest.fn<any>().mockResolvedValue(undefined),
deleteById: jest.fn<any>().mockResolvedValue(undefined),
findRecent: jest.fn<any>().mockResolvedValue([]),
};
const logger = createMockLogger();
const mocks = {
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher,
helper,
queueWorkersRepository,
logger,
...overrides?.mocks,
};
const worker = new DBQueueWorker(
mocks.logger as any,
mocks.jobsRepository as any,
mocks.monitorsRepository as any,
mocks.checksRepository as any,
mocks.checkService as any,
mocks.checkProducer as any,
mocks.checkEvaluator as any,
mocks.geoCheckPipeline as any,
mocks.dispatcher as any,
mocks.helper as any,
mocks.queueWorkersRepository as any,
overrides?.queueMode ?? "worker",
overrides?.queuePrimaryProcesses ?? true,
"worker-1"
);
return { worker, mocks };
};
describe("DBQueueWorker", () => {
let active: DBQueueWorker | null = null;
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
if (active) await active.shutdown();
active = null;
jest.clearAllTimers();
jest.useRealTimers();
});
const start = async (opts?: Parameters<typeof createWorker>[0]) => {
const ctx = createWorker(opts);
active = ctx.worker;
await ctx.worker.init();
await jest.advanceTimersByTimeAsync(1); // flush the immediate tick + any job it launched
return ctx;
};
// ── init() wiring ─────────────────────────────────────────────────────────
describe("init", () => {
it("registers the worker in the heartbeat registry on startup", async () => {
const { mocks } = await start({ queueMode: "worker" });
expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker");
});
it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => {
const { mocks } = await start({
queueMode: "primary",
queuePrimaryProcesses: false, // isolate reconcile from the polling loops
mocks: {
monitorsRepository: { findByIds: jest.fn<any>(), findAll: jest.fn<any>().mockResolvedValue([makeMonitor()]), updateById: jest.fn<any>() },
},
});
const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type);
expect(seededTypes).toContain("check");
const cleanupTypes = mocks.jobsRepository.upsertCleanupJob.mock.calls.map((c: any[]) => c[0].type);
expect(cleanupTypes).toContain("cleanup-orphaned");
expect(cleanupTypes).toContain("cleanup-retention");
});
it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => {
const geoMonitor = makeMonitor({ type: "http", geoCheckEnabled: true });
const { mocks } = await start({
queueMode: "primary",
queuePrimaryProcesses: false,
mocks: {
monitorsRepository: { findByIds: jest.fn<any>(), findAll: jest.fn<any>().mockResolvedValue([geoMonitor]), updateById: jest.fn<any>() },
},
});
const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type);
expect(seededTypes).toContain("geo-check");
});
it("worker mode does not reconcile (never seeds jobs)", async () => {
const { mocks } = await start({ queueMode: "worker" });
expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled();
expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled();
});
it("starts a polling loop per job type, each claiming up to its concurrency cap", async () => {
const { mocks } = await start({ queueMode: "worker" });
const claimedTypes = mocks.jobsRepository.claimDueBatch.mock.calls.map((c: any[]) => c[0]);
expect(claimedTypes).toEqual(expect.arrayContaining(["check", "geo-check", "evaluate", "cleanup-orphaned", "cleanup-retention"]));
expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("check", 500, expect.any(Number));
expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("evaluate", 20, expect.any(Number));
expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("cleanup-orphaned", 1, expect.any(Number));
});
it("does not start polling loops when primary and queuePrimaryProcesses is false", async () => {
const { mocks } = await start({ queueMode: "primary", queuePrimaryProcesses: false });
expect(mocks.jobsRepository.claimDueBatch).not.toHaveBeenCalled();
});
it("create() constructs the worker and runs init", async () => {
const { mocks } = createWorker(); // reuse the mock set; the un-init'd instance is discarded
const worker = await DBQueueWorker.create(
mocks.logger as any,
mocks.jobsRepository as any,
mocks.monitorsRepository as any,
mocks.checksRepository as any,
mocks.checkService as any,
mocks.checkProducer as any,
mocks.checkEvaluator as any,
mocks.geoCheckPipeline as any,
mocks.dispatcher as any,
mocks.helper as any,
mocks.queueWorkersRepository as any,
"worker",
true,
"worker-1"
);
active = worker;
await jest.advanceTimersByTimeAsync(1);
expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker");
});
});
// ── runJob: the per-type execution paths ────────────────────────────────────
describe("runJob", () => {
// Hand the "check" loop exactly one job, then nothing, so the assertion target runs once.
const claimOnce = (job: Job) => {
let handed = false;
return jest.fn<any>(async (type: string) => {
if (type === job.type && !handed) {
handed = true;
return [job];
}
return [];
});
};
it("runs a check job and reschedules it on success", async () => {
const job = makeJob({ type: "check", refId: "m1", intervalMs: 60000 });
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } });
expect(mocks.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1");
expect(mocks.checkProducer.produce).toHaveBeenCalled();
// The check stage only produces; the buffer arms evaluate once the check is durably stored
expect(mocks.jobsRepository.upsertEvaluate).not.toHaveBeenCalled();
expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalledWith(job.id, job.nextScheduledAt, job.intervalMs, expect.any(Number));
expect(mocks.jobsRepository.recordFailure).not.toHaveBeenCalled();
});
it("records a failure (and not a success) when the job handler throws", async () => {
const job = makeJob({ type: "check" });
const failingProducer = { produce: jest.fn<any>().mockRejectedValue(new Error("network down")) };
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: failingProducer },
});
expect(mocks.jobsRepository.recordFailure).toHaveBeenCalledWith(job.id, expect.any(Error), expect.any(Number));
expect(mocks.jobsRepository.recordSuccess).not.toHaveBeenCalled();
});
it("parks a one-shot job (intervalMs null) via recordOneShot instead of rescheduling", async () => {
const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null });
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } });
expect(mocks.jobsRepository.recordOneShot).toHaveBeenCalledWith(job.id, expect.any(Number));
expect(mocks.jobsRepository.recordSuccess).not.toHaveBeenCalled();
});
it("evaluate job dispatches each unevaluated check and advances lastEvaluatedAt", async () => {
const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null });
const checksRepository = { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }]) };
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checksRepository },
});
expect(mocks.checkEvaluator.evaluate).toHaveBeenCalled();
expect(mocks.dispatcher.dispatch).toHaveBeenCalled();
expect(mocks.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { lastEvaluatedAt: 12345 });
});
it("evaluate job reads the monitor once and threads the post-write monitor across the backlog", async () => {
const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null });
const initialMonitor = makeMonitor();
const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1
const monitorsRepository = {
findByIdLean: jest.fn<any>().mockResolvedValue(initialMonitor),
updateById: jest.fn<any>().mockResolvedValue({}),
};
const checksRepository = { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }, { id: "c2" }]) };
const checkEvaluator = {
evaluate: jest.fn<any>().mockResolvedValue({ monitor: postWriteMonitor, statusChange: { monitor: postWriteMonitor }, decision: {} }),
};
const { mocks } = await start({
mocks: {
jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) },
monitorsRepository,
checksRepository,
checkEvaluator,
},
});
// The monitor is read once for the whole backlog, not once per check
expect(mocks.monitorsRepository.findByIdLean).toHaveBeenCalledTimes(1);
expect(mocks.checkEvaluator.evaluate).toHaveBeenCalledTimes(2);
// First check evaluates against the freshly-read monitor; the second against the post-write monitor from the first
expect(mocks.checkEvaluator.evaluate.mock.calls[0][2]).toBe(initialMonitor);
expect(mocks.checkEvaluator.evaluate.mock.calls[1][2]).toBe(postWriteMonitor);
});
it("cleanup-orphaned job runs the helper's cleanup function", async () => {
const job = makeJob({ id: "cleanup-orphaned", type: "cleanup-orphaned", refId: null, intervalMs: 86400000 });
const cleanupFn = jest.fn<any>().mockResolvedValue(undefined);
const helper = {
getCleanupOrphanedJob: jest.fn<any>().mockReturnValue(cleanupFn),
getCleanupRetentionJob: jest.fn<any>().mockReturnValue(jest.fn<any>().mockResolvedValue(undefined)),
};
await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, helper } });
expect(cleanupFn).toHaveBeenCalled();
});
it("cleanup-retention job runs the helper's retention function", async () => {
const job = makeJob({ id: "cleanup-retention", type: "cleanup-retention", refId: null, intervalMs: 86400000 });
const retentionFn = jest.fn<any>().mockResolvedValue(undefined);
const helper = {
getCleanupOrphanedJob: jest.fn<any>().mockReturnValue(jest.fn<any>().mockResolvedValue(undefined)),
getCleanupRetentionJob: jest.fn<any>().mockReturnValue(retentionFn),
};
await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, helper } });
expect(retentionFn).toHaveBeenCalled();
});
it("geo-check job runs the geo pipeline (no evaluate handoff)", async () => {
const job = makeJob({ id: "geo-check:m1", type: "geo-check", refId: "m1", intervalMs: 300000 });
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } });
expect(mocks.geoCheckPipeline.run).toHaveBeenCalled();
expect(mocks.dispatcher.dispatch).not.toHaveBeenCalled();
expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalledWith(job.id, job.nextScheduledAt, job.intervalMs, expect.any(Number));
});
it("renews the lock while a slow job is still running", async () => {
const job = makeJob({ type: "check" });
const gate = deferred<{ status: unknown; check: unknown }>();
const slowProducer = { produce: jest.fn<any>().mockReturnValue(gate.promise) };
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: slowProducer },
});
// Job is parked on produce(); push past one renewal interval.
await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS);
expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledWith([job.id], expect.any(Number));
// Let it finish; success is still recorded.
gate.resolve({ status: { status: "up" }, check: { id: "c1" } });
await jest.advanceTimersByTimeAsync(1);
expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalled();
});
it("stops renewing once the lock is lost to another worker", async () => {
const job = makeJob({ type: "check" });
const gate = deferred<{ status: unknown; check: unknown }>();
const slowProducer = { produce: jest.fn<any>().mockReturnValue(gate.promise) };
const jobsRepository = {
...createWorker().mocks.jobsRepository,
claimDueBatch: claimOnce(job),
renewLocks: jest.fn<any>().mockResolvedValue(0),
};
const { mocks } = await start({ mocks: { jobsRepository, checkProducer: slowProducer } });
await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS);
expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledTimes(1); // lease lost → renewal timer stops
expect(mocks.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Lost lock") }));
// A second interval passes without another renewal attempt.
await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS);
expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledTimes(1);
gate.resolve({ status: { status: "up" }, check: { id: "c1" } });
await jest.advanceTimersByTimeAsync(1);
});
});
// ── monitor lifecycle delegation ────────────────────────────────────────────
describe("job lifecycle methods", () => {
it("addJob seeds a check row, plus a geo row for geo-enabled monitors", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.addJob("m1", makeMonitor({ geoCheckEnabled: true }));
const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type);
expect(types).toContain("check");
expect(types).toContain("geo-check");
});
it("addJob schedules new jobs to run immediately (no startup jitter)", async () => {
const { worker, mocks } = createWorker();
active = worker;
const now = Date.now();
await worker.addJob("m1", makeMonitor({ geoCheckEnabled: true }));
for (const [seed] of mocks.jobsRepository.upsertJob.mock.calls as any[][]) {
expect(seed.nextScheduledAt).toBe(now);
}
});
it("addJob seeds only a check row for non-geo monitors", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.addJob("m1", makeMonitor({ geoCheckEnabled: false }));
const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type);
expect(types).toEqual(["check"]);
});
it("deleteJob removes every row for the monitor", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.deleteJob(makeMonitor());
expect(mocks.jobsRepository.deleteById).toHaveBeenCalledWith("m1");
});
it("pauseJob and resumeJob flip the active flag", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.pauseJob(makeMonitor());
expect(mocks.jobsRepository.setActiveById).toHaveBeenCalledWith("m1", false);
await worker.resumeJob(makeMonitor());
expect(mocks.jobsRepository.setActiveById).toHaveBeenCalledWith("m1", true);
});
it("updateJob reschedules the check row and drops the geo row when geo is disabled", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.updateJob(makeMonitor({ geoCheckEnabled: false, interval: 30000 }));
expect(mocks.jobsRepository.updateScheduleById).toHaveBeenCalledWith("m1", "check", 30000);
expect(mocks.jobsRepository.deleteByIdAndType).toHaveBeenCalledWith("m1", "geo-check");
});
it("updateJob upserts the geo row when geo is enabled", async () => {
const { worker, mocks } = createWorker();
active = worker;
await worker.updateJob(makeMonitor({ geoCheckEnabled: true }));
const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type);
expect(types).toContain("geo-check");
expect(mocks.jobsRepository.deleteByIdAndType).not.toHaveBeenCalled();
});
});
// ── poll backoff + wake() ───────────────────────────────────────────────────
//
// An idle loop grows its poll interval up to POLL_MAX_MS (5s). wake() fast-paths
// it back so a freshly-enqueued "run now" job doesn't wait out that backoff.
describe("poll backoff and wake", () => {
const POLL_MAX_MS = 5000;
const claims = (mocks: any, type: string) => mocks.jobsRepository.claimDueBatch.mock.calls.filter((c: any[]) => c[0] === type).length;
it("backs off the idle poll interval toward POLL_MAX_MS", async () => {
const { mocks } = await start({ queueMode: "worker" }); // claimDueBatch returns [] → always idle
await jest.advanceTimersByTimeAsync(15000); // let the check loop grow to the cap
const before = claims(mocks, "check");
await jest.advanceTimersByTimeAsync(POLL_MAX_MS * 2); // ~2 polls at a 5s cadence
const delta = claims(mocks, "check") - before;
// At the cap that's ~2 polls; at the un-backed-off 250ms cadence it would be ~40.
expect(delta).toBeGreaterThanOrEqual(1);
expect(delta).toBeLessThanOrEqual(4);
});
it("resets the poll interval to POLL_MS once a tick finds work after being idle", async () => {
let busy = false;
const claimDueBatch = jest.fn<any>(async (type: string) => (busy && type === "check" ? [makeJob()] : []));
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch } } });
await jest.advanceTimersByTimeAsync(15000); // idle → backed off to the 5s cap
busy = true;
await jest.advanceTimersByTimeAsync(POLL_MAX_MS); // the next (backed-off) tick fires, finds work → resets pollMs to POLL_MS
const before = claims(mocks, "check");
await jest.advanceTimersByTimeAsync(POLL_MS * 2); // 500ms
// Polling fast again (~2 in 500ms), not once per 5s — proves the interval reset on finding work.
expect(claims(mocks, "check") - before).toBeGreaterThanOrEqual(1);
});
it("does not back off a loop that is at capacity (capacity === 0)", async () => {
// cleanup-orphaned has concurrency 1; hold its one job in-flight so capacity stays 0.
// The backoff condition is `capacity > 0 && jobs.length === 0`, so an at-capacity loop
// must keep polling at POLL_MS (a freed slot should be picked up promptly, not after 5s).
const gate = deferred<void>();
let handed = false;
const claimDueBatch = jest.fn<any>(async (type: string) => {
if (type === "cleanup-orphaned" && !handed) {
handed = true;
return [makeJob({ id: "cleanup-orphaned", type: "cleanup-orphaned", refId: null, intervalMs: 86400000 })];
}
return [];
});
const helper = {
getCleanupOrphanedJob: jest.fn<any>().mockReturnValue(jest.fn<any>(() => gate.promise)), // blocks → keeps the slot full
getCleanupRetentionJob: jest.fn<any>().mockReturnValue(jest.fn<any>().mockResolvedValue(undefined)),
};
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch }, helper } });
const before = claims(mocks, "cleanup-orphaned"); // the blocking job is now in-flight → capacity 0
await jest.advanceTimersByTimeAsync(1000);
const delta = claims(mocks, "cleanup-orphaned") - before;
// ~4 polls at POLL_MS; if an at-capacity loop wrongly backed off it would be ~1.
expect(delta).toBeGreaterThanOrEqual(3);
gate.resolve();
await jest.advanceTimersByTimeAsync(1); // let the held job finish for a clean shutdown
});
it("wake() re-fires an idle, backed-off loop immediately", async () => {
const { worker, mocks } = await start({ queueMode: "worker" });
await jest.advanceTimersByTimeAsync(15000); // back off the check loop to the cap
const before = claims(mocks, "check");
worker.wake("check");
await jest.advanceTimersByTimeAsync(1);
expect(claims(mocks, "check")).toBe(before + 1); // fired now, not 5s later
});
it("wake() resets the cadence so the loop polls fast again, not at the 5s cap", async () => {
const { worker, mocks } = await start({ queueMode: "worker" });
await jest.advanceTimersByTimeAsync(15000); // backed off to the cap
worker.wake("check");
await jest.advanceTimersByTimeAsync(1); // immediate tick (still idle → next poll at ~POLL_MS*2)
const before = claims(mocks, "check");
await jest.advanceTimersByTimeAsync(POLL_MS * 2); // 500ms
// Polled again well within the old 5s cap, proving the interval was reset.
expect(claims(mocks, "check")).toBe(before + 1);
});
it("wake() is a no-op for a loop that isn't running in this process", async () => {
const { worker, mocks } = await start({ queueMode: "primary", queuePrimaryProcesses: false }); // no loops
expect(() => worker.wake("check")).not.toThrow();
await jest.advanceTimersByTimeAsync(1);
expect(claims(mocks, "check")).toBe(0);
});
it("addJob wakes the check loop so a new monitor's first check runs promptly", async () => {
const { worker, mocks } = await start({ queueMode: "worker" });
await jest.advanceTimersByTimeAsync(15000); // backed off to the cap
const before = claims(mocks, "check");
await worker.addJob("m2", makeMonitor({ id: "m2" }));
await jest.advanceTimersByTimeAsync(1);
expect(claims(mocks, "check")).toBe(before + 1);
});
it("resumeJob wakes the check loop so a resumed monitor runs promptly", async () => {
const { worker, mocks } = await start({ queueMode: "worker" });
await jest.advanceTimersByTimeAsync(15000); // backed off to the cap
const before = claims(mocks, "check");
await worker.resumeJob(makeMonitor());
await jest.advanceTimersByTimeAsync(1);
expect(claims(mocks, "check")).toBe(before + 1);
});
});
// ── shutdown ────────────────────────────────────────────────────────────────
describe("shutdown", () => {
it("deregisters the worker and stops the polling loops", async () => {
const { worker, mocks } = createWorker({ queueMode: "worker" });
await worker.init();
await jest.advanceTimersByTimeAsync(1);
const claimsAfterInit = mocks.jobsRepository.claimDueBatch.mock.calls.length;
await worker.shutdown();
expect(mocks.queueWorkersRepository.deleteById).toHaveBeenCalledWith("worker-1");
// Loops must not re-arm after shutdown.
await jest.advanceTimersByTimeAsync(POLL_MS * 4);
expect(mocks.jobsRepository.claimDueBatch.mock.calls.length).toBe(claimsAfterInit);
// already shut down; keep afterEach from double-shutting-down
active = null;
});
});
// ── flushQueues ───────────────────────────────────────────────────────────────
describe("flushQueues", () => {
it("restarts the worker and reports success", async () => {
const { worker, mocks } = createWorker({ queueMode: "worker" });
active = worker;
await worker.init();
await jest.advanceTimersByTimeAsync(1);
const result = await worker.flushQueues();
await jest.advanceTimersByTimeAsync(1);
expect(result).toEqual({ success: true });
expect(mocks.queueWorkersRepository.deleteById).toHaveBeenCalledWith("worker-1"); // shutdown leg
expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledTimes(2); // init + re-init
});
});
// ── getMetrics ──────────────────────────────────────────────────────────────
describe("getMetrics", () => {
it("aggregates run/fail counts, active leases, and failing jobs across all rows", async () => {
const now = Date.now();
const rows = [
makeJob({ id: "a", runCount: 5, failCount: 0, lockedBy: "w", lockedUntil: now + 10000 }), // active lease
makeJob({ id: "b", runCount: 2, failCount: 3, lastFailReason: "boom", lockedBy: null, lockedUntil: null }), // failing
makeJob({ id: "c", runCount: 1, failCount: 0, lockedBy: "w", lockedUntil: now - 10000 }), // expired lease, not active
];
const { worker, mocks } = createWorker();
active = worker;
mocks.jobsRepository.findAll.mockResolvedValue(rows);
mocks.queueWorkersRepository.findRecent.mockResolvedValue([{ workerId: "w" }]);
const metrics = await worker.getMetrics();
expect(metrics.jobs).toBe(3);
expect(metrics.totalRuns).toBe(8);
expect(metrics.totalFailures).toBe(3);
expect(metrics.activeJobs).toBe(1);
expect(metrics.failingJobs).toBe(1);
expect(metrics.jobsWithFailures).toHaveLength(1);
expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ failCount: 3, failReason: "boom" }));
expect(metrics.workers).toEqual([{ workerId: "w" }]);
});
});
// ── getJobs ─────────────────────────────────────────────────────────────────
describe("getJobs", () => {
it("maps rows to summaries and passes the total count through", async () => {
const { worker, mocks } = createWorker();
active = worker;
mocks.jobsRepository.findPage.mockResolvedValue({
jobs: [makeJob({ id: "check:m1", refId: "m1", type: "check", intervalMs: 60000 })],
count: 1,
});
const page = await worker.getJobs({ page: 0, rowsPerPage: 10 });
expect(page.count).toBe(1);
expect(page.jobs).toHaveLength(1);
expect(page.jobs[0]).toEqual(expect.objectContaining({ monitorId: "m1", monitorType: "check", monitorInterval: 60000 }));
});
it("falls back to the job id for monitorId when refId is null (global jobs)", async () => {
const { worker, mocks } = createWorker();
active = worker;
mocks.jobsRepository.findPage.mockResolvedValue({
jobs: [makeJob({ id: "cleanup-orphaned", refId: null, type: "cleanup-orphaned" })],
count: 1,
});
const page = await worker.getJobs({ page: 0, rowsPerPage: 10 });
expect(page.jobs[0].monitorId).toBe("cleanup-orphaned");
});
});
});
@@ -104,9 +104,8 @@ describe("DiagnosticService", () => {
it("returns only the slimmed diagnostic shape (no serverStatus/serverInfo/memoryUsage)", async () => {
const service = new DiagnosticService(makeDb());
const promise = service.getSystemStats();
// getCPUUsagePercentage waits 1s, then event loop delay waits 0ms
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
// getCPUUsagePercentage waits 1s, the first mongo ops sample waits another 1s, then event loop delay waits 0ms
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(Object.keys(result).sort()).toEqual(["cpuUsage", "eventLoopDelayMs", "mongoStats", "osStats", "uptimeMs", "v8HeapStats"].sort());
@@ -131,11 +130,24 @@ describe("DiagnosticService", () => {
});
const service = new DiagnosticService(db);
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(Object.keys(result.mongoStats).sort()).toEqual(["collections", "dbName", "host", "port", "readyState", "totalSize"].sort());
expect(Object.keys(result.mongoStats).sort()).toEqual(
[
"collections",
"dbName",
"host",
"port",
"readyState",
"totalSize",
"readsPerSecond",
"insertsPerSecond",
"updatesPerSecond",
"deletesPerSecond",
"writesPerSecond",
].sort()
);
expect(result.mongoStats.totalSize).toBe(9999);
expect(result.mongoStats.collections).toHaveLength(2);
expect(result.mongoStats.collections[0]).toEqual({
@@ -154,8 +166,7 @@ describe("DiagnosticService", () => {
});
const service = new DiagnosticService(db);
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(result.mongoStats.totalSize).toBe(376832 + 1155072);
@@ -169,8 +180,7 @@ describe("DiagnosticService", () => {
});
const service = new DiagnosticService(db);
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
const coll = result.mongoStats.collections[0];
@@ -190,8 +200,7 @@ describe("DiagnosticService", () => {
});
const service = new DiagnosticService(db);
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(result.mongoStats.collections[0]?.bucketCount).toBe(12);
@@ -199,8 +208,7 @@ describe("DiagnosticService", () => {
const db2 = makeDb({ collections: [{ collectionName: "monitors" }] });
const service2 = new DiagnosticService(db2);
const promise2 = service2.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result2 = await promise2;
expect(result2.mongoStats.collections[0]).not.toHaveProperty("bucketCount");
});
@@ -235,8 +243,7 @@ describe("DiagnosticService", () => {
performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any;
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
await jest.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(result.eventLoopDelayMs).toBe(0);
@@ -1,791 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
// ── Mock Scheduler / MongoStore ──────────────────────────────────────────────
const createScheduler = () => {
const listeners: Record<string, Function[]> = {};
return {
workerId: "host-1:1234:abcd",
on: jest.fn((event: string, cb: Function) => {
listeners[event] = listeners[event] || [];
listeners[event].push(cb);
}),
off: jest.fn((event: string, cb: Function) => {
listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb);
}),
emit: (event: string, ...args: unknown[]) => {
(listeners[event] || []).forEach((cb) => cb(...args));
},
start: jest.fn().mockResolvedValue(true),
stop: jest.fn().mockResolvedValue(true),
addTemplate: jest.fn(),
addJob: jest.fn(),
addJobs: jest.fn().mockResolvedValue({ inserted: 0, upserted: 0, modified: 0, failures: [] }),
removeJob: jest.fn(),
getJob: jest.fn().mockResolvedValue(null),
getJobs: jest.fn().mockResolvedValue([]),
countJobs: jest.fn().mockResolvedValue(0),
getStats: jest.fn().mockResolvedValue({ jobs: 0, activeJobs: 0, failingJobs: 0, totalRuns: 0, totalFailures: 0, jobsWithFailures: [] }),
pauseJob: jest.fn().mockResolvedValue(true),
resumeJob: jest.fn().mockResolvedValue(true),
updateJob: jest.fn(),
flushJobs: jest.fn().mockResolvedValue(true),
};
};
const mockSchedulerInstance = createScheduler();
const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance);
const MockMongoStore = jest.fn();
jest.unstable_mockModule("less-simple-scheduler", () => ({
Scheduler: MockScheduler,
MongoStore: MockMongoStore,
}));
const { LessSimpleWorker: LessSimpleQueue } = await import("../../../src/worker/worker.less-simple.ts");
const createQueueHelper = () => ({
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()),
});
const createMonitorsRepo = () => ({
findAll: jest.fn().mockResolvedValue([]),
});
const createWorkersRepo = () => ({
upsert: jest.fn().mockResolvedValue(undefined),
findRecent: jest.fn().mockResolvedValue([]),
deleteById: jest.fn().mockResolvedValue(undefined),
});
const createQueue = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const workersRepository = createWorkersRepo();
const scheduler = createScheduler();
const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, queueMode: "primary", ...overrides };
const queue = new LessSimpleQueue(
defaults.logger as any,
defaults.helper as any,
defaults.monitorsRepository as any,
defaults.workersRepository as any,
defaults.scheduler as any,
defaults.queueMode as any
);
return { queue, ...defaults };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
interval: 60000,
isActive: true,
geoCheckEnabled: false,
geoCheckInterval: 300000,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("LessSimpleQueue", () => {
describe("serviceName", () => {
it("returns JobQueue", () => {
const { queue } = createQueue();
expect(queue.serviceName).toBe("JobQueue");
});
});
// ── init (primary) ─────────────────────────────────────────────────────────
describe("init - primary mode", () => {
it("starts scheduler, registers all templates, seeds monitors and system jobs", async () => {
const { queue, scheduler, monitorsRepository } = createQueue();
const result = await queue.init();
expect(result).toBe(true);
expect(scheduler.start).toHaveBeenCalled();
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
expect(monitorsRepository.findAll).toHaveBeenCalled();
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
});
it("registers existing monitors in a single bulk write", async () => {
const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })];
const { queue, scheduler, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors);
await queue.init();
expect(scheduler.addJobs).toHaveBeenCalledTimes(1);
expect(scheduler.addJobs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: "m1", template: "monitor-job", jitter: true }),
expect.objectContaining({ id: "m2", template: "monitor-job", jitter: true }),
])
);
});
it("returns true and skips system jobs when findAll returns null", async () => {
const { queue, monitorsRepository, scheduler } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(null);
const result = await queue.init();
expect(result).toBe(true);
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
});
it("returns false and logs error on failure", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down"));
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") }));
});
it("logs error with String(error) for non-Error exceptions", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error");
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") }));
});
});
// ── init (worker) ──────────────────────────────────────────────────────────
describe("init - worker mode", () => {
it("starts scheduler and registers all templates", async () => {
const { queue, scheduler } = createQueue();
const result = await queue.init("worker");
expect(result).toBe(true);
expect(scheduler.start).toHaveBeenCalled();
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
});
it("does NOT seed monitor jobs (no findAll, primary populates the queue)", async () => {
const { queue, monitorsRepository } = createQueue();
await queue.init("worker");
expect(monitorsRepository.findAll).not.toHaveBeenCalled();
});
it("does NOT seed cleanup system jobs", async () => {
const { queue, scheduler } = createQueue();
await queue.init("worker");
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
expect(scheduler.addJob).not.toHaveBeenCalled();
});
it("logs the initialized worker with its workerId", async () => {
const { queue, logger } = createQueue();
await queue.init("worker");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Worker queue: host-1:1234:abcd initialized" }));
});
});
// ── registerListeners ──────────────────────────────────────────────────────
describe("registerListeners (via constructor)", () => {
it("logs on scheduler:start", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("scheduler:start", "host-1:1234:abcd");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" }));
});
it("logs on scheduler:stop", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("scheduler:stop", "host-1:1234:abcd");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" }));
});
it("logs on scheduler:error with Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("scheduler:error", "w1", new Error("boom"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: boom" }));
});
it("logs on scheduler:error with non-Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("scheduler:error", "w1", "string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: string error", stack: undefined }));
});
it("logs on job:locked", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:locked", "w1", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 locked" }));
});
it("logs on job:abort", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:abort", "w1", { id: "m1" }, "timeout");
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 aborted: timeout" }));
});
it("logs on job:attempt", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:attempt", "w1", { id: "m1" }, 2);
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 attempt 2" }));
});
it("logs on job:complete", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:complete", "w1", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 completed successfully" }));
});
it("logs on job:exhausted with Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:exhausted", "w1", { id: "m1" }, new Error("gave up"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up" }));
});
it("logs on job:exhausted with non-Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:exhausted", "w1", { id: "m1" }, "gave up");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up", stack: undefined }));
});
it("logs on job:fail with Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:fail", "w1", { id: "m1" }, new Error("oops"), 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops" }));
});
it("logs on job:fail with non-Error", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:fail", "w1", { id: "m1" }, "oops", 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops", stack: undefined }));
});
it("logs on job:start", () => {
const { scheduler, logger } = createQueue();
scheduler.emit("job:start", "w1", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 started" }));
});
});
// ── addJob ─────────────────────────────────────────────────────────────────
describe("addJob", () => {
it("adds monitor job to scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor());
expect(scheduler.addJobs).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })])
);
});
it("adds geo check job when geoCheckEnabled and type supports it", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }));
expect(scheduler.addJobs).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })])
);
});
it("skips geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })]));
});
it("skips geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })]));
});
});
// ── deleteJob ──────────────────────────────────────────────────────────────
describe("deleteJob", () => {
it("removes monitor job", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1");
});
it("removes geo job if it exists", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
});
// ── pauseJob ───────────────────────────────────────────────────────────────
describe("pauseJob", () => {
it("pauses monitor and geo job when geo exists", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
});
it("does not pause geo job when it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("throws when scheduler.pauseJob returns false for the main job", async () => {
const { queue, scheduler } = createQueue();
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
});
it("logs error but does not throw when geo pauseJob returns false", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
(scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" }));
});
});
// ── resumeJob ──────────────────────────────────────────────────────────────
describe("resumeJob", () => {
it("resumes monitor and geo job when geo exists", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
});
it("does not resume geo job when it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("throws when scheduler.resumeJob returns false for the main job", async () => {
const { queue, scheduler } = createQueue();
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
});
it("logs error but does not throw when geo resumeJob returns false", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
(scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" }));
});
});
// ── updateJob / syncGeoJob ─────────────────────────────────────────────────
describe("updateJob", () => {
it("updates monitor job with new interval", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ interval: 120000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor }));
});
it("removes existing geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("removes existing geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("updates existing geo job when geoCheckEnabled", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 }));
});
it("creates new geo job when enabled but no existing job", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" }));
});
});
// ── shutdown ───────────────────────────────────────────────────────────────
describe("shutdown", () => {
it("stops the scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.shutdown();
expect(scheduler.stop).toHaveBeenCalled();
});
});
// ── getMetrics ─────────────────────────────────────────────────────────────
describe("getMetrics", () => {
it("returns empty metrics when no jobs", async () => {
const { queue } = createQueue();
const metrics = await queue.getMetrics();
expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] });
});
it("maps the store's aggregate counters through", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getStats as jest.Mock).mockResolvedValue({
jobs: 2,
activeJobs: 1,
failingJobs: 1,
totalRuns: 15,
totalFailures: 2,
jobsWithFailures: [],
});
const metrics = await queue.getMetrics();
expect(metrics).toMatchObject({ jobs: 2, activeJobs: 1, failingJobs: 1, totalRuns: 15, totalFailures: 2 });
});
it("derives monitor fields for failing monitor jobs from job data", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getStats as jest.Mock).mockResolvedValue({
jobs: 1,
activeJobs: 0,
failingJobs: 1,
totalRuns: 10,
totalFailures: 2,
jobsWithFailures: [{ id: "m1", data: { url: "http://a.com", type: "http" }, failedAt: 200, failCount: 2, failReason: "timeout" }],
});
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures).toEqual([
{ monitorId: "m1", monitorUrl: "http://a.com", monitorType: "http", failedAt: 200, failCount: 2, failReason: "timeout" },
]);
});
it("leaves monitor fields null for non-monitor (cleanup) failures", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getStats as jest.Mock).mockResolvedValue({
jobs: 1,
activeJobs: 0,
failingJobs: 1,
totalRuns: 1,
totalFailures: 1,
jobsWithFailures: [{ id: "cleanup-orphaned", data: undefined, failedAt: 100, failCount: 1, failReason: "boom" }],
});
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures[0]).toEqual({
monitorId: "cleanup-orphaned",
monitorUrl: null,
monitorType: null,
failedAt: 100,
failCount: 1,
failReason: "boom",
});
});
});
// ── getMetrics: worker registry ──────────────────────────────────────────────
describe("getMetrics - worker registry", () => {
it("returns the alive workers from the registry", async () => {
const { queue, workersRepository } = createQueue();
const alive = [
{ workerId: "host:1:aaa", mode: "primary", lastSeenAt: 1000 },
{ workerId: "host:2:bbb", mode: "worker", lastSeenAt: 2000 },
];
(workersRepository.findRecent as jest.Mock).mockResolvedValue(alive);
const metrics = await queue.getMetrics();
expect(workersRepository.findRecent).toHaveBeenCalledWith(30000);
expect(metrics.workers).toEqual(alive);
});
});
// ── getJobs ────────────────────────────────────────────────────────────────
describe("getJobs", () => {
it("maps scheduler jobs to summaries", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{
id: "m1",
template: "monitor-job",
active: true,
repeat: 60000,
lockedAt: null,
runCount: 5,
failCount: 1,
lastError: "timeout",
lastStartedAt: 1000,
lastFinishedAt: 1100,
lastFailedAt: 900,
data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true },
},
]);
const { jobs } = await queue.getJobs({});
expect(jobs).toHaveLength(1);
expect(jobs[0]).toEqual({
monitorId: "m1",
monitorUrl: "http://a.com",
monitorType: "http",
monitorInterval: 60000,
monitorGeoInterval: 300000,
monitorActive: true,
active: true,
repeat: 60000,
lockedBy: null,
lockedUntil: null,
lockedAt: null,
runCount: 5,
failCount: 1,
failReason: "timeout",
lastRunAt: 1000,
lastFinishedAt: 1100,
lastRunTook: 100,
lastFailedAt: 900,
});
});
it("returns null lastRunTook when job is locked", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{
id: "m1",
template: "monitor-job",
active: true,
lockedAt: 999,
runCount: 1,
lastStartedAt: 1000,
lastFinishedAt: 1100,
data: { url: "a", type: "http", interval: 60000 },
},
]);
const { jobs } = await queue.getJobs({});
expect(jobs[0].lastRunTook).toBeNull();
});
it("leaves monitor fields null for non-monitor jobs", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, data: undefined }]);
const { jobs } = await queue.getJobs({});
expect(jobs[0].monitorUrl).toBeNull();
expect(jobs[0].monitorType).toBeNull();
expect(jobs[0].monitorInterval).toBeNull();
expect(jobs[0].monitorGeoInterval).toBeNull();
expect(jobs[0].monitorActive).toBeNull();
});
it("pushes pagination to the scheduler and returns its total count", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", template: "monitor-job", active: true, data: { url: "a", type: "http", interval: 60000 } },
]);
(scheduler.countJobs as jest.Mock).mockResolvedValue(42);
const { jobs, count } = await queue.getJobs({ page: 2, rowsPerPage: 10 });
expect(scheduler.getJobs).toHaveBeenCalledWith({ skip: 20, limit: 10 });
expect(count).toBe(42);
expect(jobs).toHaveLength(1);
});
it("requests all jobs when rowsPerPage is omitted", async () => {
const { queue, scheduler } = createQueue();
(scheduler.countJobs as jest.Mock).mockResolvedValue(7);
const { count } = await queue.getJobs({});
expect(scheduler.getJobs).toHaveBeenCalledWith(undefined);
expect(count).toBe(7);
});
});
// ── flushQueues ────────────────────────────────────────────────────────────
describe("flushQueues", () => {
it("stops, flushes, and reinitializes", async () => {
const { queue, scheduler } = createQueue();
const result = await queue.flushQueues();
expect(scheduler.stop).toHaveBeenCalled();
expect(scheduler.flushJobs).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it("flushes before stopping, so the store is still connected for removeAll", async () => {
const { queue, scheduler } = createQueue();
const order: string[] = [];
(scheduler.flushJobs as jest.Mock).mockImplementation(async () => {
order.push("flush");
return true;
});
(scheduler.stop as jest.Mock).mockImplementation(async () => {
order.push("stop");
return true;
});
await queue.flushQueues();
expect(order).toEqual(["flush", "stop"]);
});
it("refuses to flush on a non-primary worker without touching the scheduler", async () => {
const { queue, scheduler } = createQueue({ queueMode: "worker" });
const result = await queue.flushQueues();
expect(result.success).toBe(false);
expect(scheduler.flushJobs).not.toHaveBeenCalled();
expect(scheduler.stop).not.toHaveBeenCalled();
});
it("returns false when flush fails", async () => {
const { queue, scheduler } = createQueue();
(scheduler.flushJobs as jest.Mock).mockResolvedValue(false);
const result = await queue.flushQueues();
expect(result.success).toBe(false);
});
});
// ── static create ──────────────────────────────────────────────────────────
describe("static create", () => {
const envSettings = { dbConnectionString: "mongodb://localhost:27017/test_db" } as any;
it("creates a MongoStore + Scheduler and inits in primary mode", async () => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const workersRepository = createWorkersRepo();
const instance = await LessSimpleQueue.create(
logger as any,
helper as any,
monitorsRepository as any,
workersRepository as any,
envSettings,
"primary"
);
expect(MockMongoStore).toHaveBeenCalledWith(expect.objectContaining({ url: "mongodb://localhost:27017/test_db" }));
expect(MockScheduler).toHaveBeenCalled();
expect(instance).toBeInstanceOf(LessSimpleQueue);
expect(mockSchedulerInstance.start).toHaveBeenCalled();
expect(monitorsRepository.findAll).toHaveBeenCalled();
});
it("inits in worker mode without seeding monitors", async () => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const workersRepository = createWorkersRepo();
const instance = await LessSimpleQueue.create(
logger as any,
helper as any,
monitorsRepository as any,
workersRepository as any,
envSettings,
"worker"
);
expect(instance).toBeInstanceOf(LessSimpleQueue);
expect(monitorsRepository.findAll).not.toHaveBeenCalled();
});
});
// ── worker heartbeat / deregistration ────────────────────────────────────────
describe("worker heartbeat", () => {
it("writes a heartbeat on init with the workerId and mode (primary)", async () => {
const { queue, workersRepository } = createQueue();
await queue.init("primary");
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "primary");
});
it("writes a heartbeat on init with the workerId and mode (worker)", async () => {
const { queue, workersRepository } = createQueue();
await queue.init("worker");
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker");
});
it("beats again on each scheduler:heartbeat event", async () => {
const { queue, scheduler, workersRepository } = createQueue();
await queue.init("worker");
(workersRepository.upsert as jest.Mock).mockClear();
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
expect(workersRepository.upsert).toHaveBeenCalledTimes(2);
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker");
});
it("stops beating after shutdown", async () => {
const { queue, scheduler, workersRepository } = createQueue();
await queue.init("worker");
await queue.shutdown();
(workersRepository.upsert as jest.Mock).mockClear();
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
expect(workersRepository.upsert).not.toHaveBeenCalled();
});
it("deregisters the worker and stops the scheduler on shutdown", async () => {
const { queue, scheduler, workersRepository } = createQueue();
await queue.init("worker");
await queue.shutdown();
expect(workersRepository.deleteById).toHaveBeenCalledWith("host-1:1234:abcd");
expect(scheduler.stop).toHaveBeenCalled();
});
});
});
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more