Merge branch 'develop' into feat/customcss-status-page
This commit is contained in:
107 files changed
+5625
-4569
No files matched your search
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)" },
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "معرّف المراقب",
|
||||
"runCount": "عدد التشغيلات",
|
||||
"failCount": "عدد حالات الفشل",
|
||||
"lastRunAt": "آخر تشغيل في",
|
||||
"lockedAt": "مقفل في",
|
||||
"lastFinishedAt": "آخر انتهاء في",
|
||||
"lastRunTook": "مدة آخر تشغيل",
|
||||
"lastFailedAt": "آخر فشل في",
|
||||
"failReason": "سبب الفشل",
|
||||
"state": "الحالة"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "モニターID",
|
||||
"runCount": "実行回数",
|
||||
"failCount": "失敗回数",
|
||||
"lastRunAt": "最終実行日時",
|
||||
"lockedAt": "ロック日時",
|
||||
"lastFinishedAt": "最終完了日時",
|
||||
"lastRunTook": "最終実行時間",
|
||||
"lastFailedAt": "最終失敗日時",
|
||||
"failReason": "失敗理由",
|
||||
"state": "状態"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "ID монитора",
|
||||
"runCount": "Кол-во запусков",
|
||||
"failCount": "Кол-во ошибок",
|
||||
"lastRunAt": "Последний запуск",
|
||||
"lockedAt": "Заблокировано в",
|
||||
"lastFinishedAt": "Последнее завершение",
|
||||
"lastRunTook": "Последний запуск занял",
|
||||
"lastFailedAt": "Последняя ошибка",
|
||||
"failReason": "Причина ошибки",
|
||||
"state": "Состояние"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "รหัสมอนิเตอร์",
|
||||
"runCount": "จำนวนรัน",
|
||||
"failCount": "จำนวนล้มเหลว",
|
||||
"lastRunAt": "รันล่าสุดเมื่อ",
|
||||
"lockedAt": "ล็อกเมื่อ",
|
||||
"lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ",
|
||||
"lastRunTook": "รันล่าสุดใช้เวลา",
|
||||
"lastFailedAt": "ล้มเหลวล่าสุดเมื่อ",
|
||||
"failReason": "สาเหตุที่ล้มเหลว",
|
||||
"state": "สถานะ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "ID монітора",
|
||||
"runCount": "Кількість запусків",
|
||||
"failCount": "Кількість помилок",
|
||||
"lastRunAt": "Останній запуск",
|
||||
"lockedAt": "Заблоковано о",
|
||||
"lastFinishedAt": "Останнє завершення",
|
||||
"lastRunTook": "Останній запуск тривав",
|
||||
"lastFailedAt": "Остання помилка",
|
||||
"failReason": "Причина помилки",
|
||||
"state": "Стан"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "监控 ID",
|
||||
"runCount": "运行次数",
|
||||
"failCount": "失败次数",
|
||||
"lastRunAt": "上次运行于",
|
||||
"lockedAt": "锁定于",
|
||||
"lastFinishedAt": "上次完成于",
|
||||
"lastRunTook": "上次运行耗时",
|
||||
"lastFailedAt": "上次失败于",
|
||||
"failReason": "失败原因",
|
||||
"state": "状态"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "監控 ID",
|
||||
"runCount": "執行次數",
|
||||
"failCount": "失敗次數",
|
||||
"lastRunAt": "最後執行於",
|
||||
"lockedAt": "鎖定於",
|
||||
"lastFinishedAt": "最後完成於",
|
||||
"lastRunTook": "最後執行耗時",
|
||||
"lastFailedAt": "最後失敗於",
|
||||
"failReason": "失敗原因",
|
||||
"state": "狀態"
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Generated
+1215
-988
File diff suppressed because it is too large.
Load diff
+1
-4
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user