Compare commits

..
Author SHA1 Message Date
Malena Aguiar a277a89bef feat(dashboard): add code refactors 2026-09-21 18:29:48 -06:00
Malena Aguiar 9d0dc3f330 feat(dashboard): add edit visible cards modal 2026-09-19 14:57:30 -06:00
Alexander Holliday da8b230cec Merge pull request #3957 from Asgabani/fix/temp-breach-boundary-inconsistency
fix(notifications): don't report a temperature breach exactly at the threshold
2026-09-18 09:16:23 -07:00
Alexander Holliday f4864b5455 Merge pull request #3969 from bluewave-labs/fix/dropped-check
fix:  Use Check IDs instead of timestamp to process checks
2026-09-17 12:34:19 -07:00
Alex Holliday f299098548 bug fixes 2026-09-17 12:30:56 -07:00
Alexander Holliday 87eab6f996 Merge pull request #3968 from bluewave-labs/feat/dashboard-add-redux-state
feat: remove mock data and add redux state to dashboard page
2026-09-17 12:08:30 -07:00
Malena Aguiar 31400a2502 remove mock data and add redux state 2026-09-17 12:59:39 -06:00
Alex Holliday fa0c0abcac Fix: Use Check IDs instead of timestamp to process checks 2026-09-17 11:57:19 -07:00
Alexander Holliday 9102335d3e Merge pull request #3967 from bluewave-labs/fix/cache-db-settings
fix: cache DB settings
2026-09-16 11:42:48 -07:00
Ankit Gabani b2ace1bec5 fix(notifications): don't report a temperature breach exactly at the threshold
extractThresholdBreaches used maxTemp >= threshold, while
StatusService.computeHardwareStatus (which drives the actual
"breached" status transition and incident) uses a strict
maxTemp > threshold for the same metric. cpu, memory and disk already
agree on > in both files; temp was the one metric where they diverged.

Fixes #3956.
2026-09-14 01:49:54 +05:30
23 changed files with 829 additions and 125 deletions

No files matched your search

+10
View File
@@ -2,6 +2,10 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
type TableName =
| "monitors"
| "team"
@@ -35,6 +39,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[];
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +79,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys],
};
const uiSlice = createSlice({
@@ -117,6 +123,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload;
},
},
});
@@ -133,4 +142,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardVisibleCards,
} = uiSlice.actions;
@@ -0,0 +1,71 @@
import { useState } from "react";
import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel";
import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs";
import { type DashboardCardKey, dashboardCardKeys } from "@/Features/UI/uiSlice";
export type { DashboardCardKey };
interface EditCardsModalProps {
open: boolean;
visibleCards: Set<DashboardCardKey>;
onClose: () => void;
onSave: (visibleCards: Set<DashboardCardKey>) => void;
}
export const EditCardsModal = ({
open,
visibleCards,
onClose,
onSave,
}: EditCardsModalProps) => {
const { t } = useTranslation();
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards));
const handleClose = () => {
setDraft(new Set(visibleCards));
onClose();
};
const handleToggle = (key: DashboardCardKey) => {
setDraft((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
return (
<Dialog
open={open}
title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose}
onConfirm={() => onSave(draft)}
confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs"
fullWidth
>
<Stack>
{dashboardCardKeys.map((key) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
control={
<Checkbox
checked={draft.has(key)}
disabled={draft.has(key) && draft.size === 1}
onChange={() => handleToggle(key)}
/>
}
/>
))}
</Stack>
</Dialog>
);
};
@@ -9,41 +9,50 @@ import { useTranslation } from "react-i18next";
import type { MonitorsSummary } from "@/Types/Monitor";
interface MonitorStatusCardProps {
summary: MonitorsSummary;
summary: MonitorsSummary | null;
}
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const up = summary.upMonitors;
const total = summary.totalMonitors;
const up = summary?.upMonitors ?? 0;
const total = summary?.totalMonitors ?? 0;
const ratio = total > 0 ? up / total : 0;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
{total === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
)}
</DashboardCard>
);
};
@@ -1,14 +1,16 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
import type { MonitorTypeCount } from "@/Types/Monitor";
import { getMonitorTypeLabel } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface MonitorsByTypeCardProps {
monitorsByType: DashboardTypeCount[];
monitorsByType: MonitorTypeCount[];
}
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
@@ -19,17 +21,26 @@ export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps)
return (
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
{monitorsByType.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
+80 -26
View File
@@ -1,43 +1,97 @@
import { useMemo, useState, type ReactNode } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { BasePage } from "@/Components/design-elements/BasePage";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
import {
setDashboardVisibleCards,
dashboardCardKeys,
type DashboardCardKey,
} from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import type { MonitorsSummary } from "@/Types/Monitor";
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
const MOCK_SUMMARY: MonitorsSummary = {
totalMonitors: 12,
upMonitors: 10,
downMonitors: 1,
pausedMonitors: 1,
initializingMonitors: 0,
maintenanceMonitors: 0,
breachedMonitors: 0,
};
const MOCK_MONITORS_BY_TYPE: DashboardTypeCount[] = [
{ type: "http", count: 7 },
{ type: "ping", count: 3 },
{ type: "port", count: 2 },
];
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const [editOpen, setEditOpen] = useState(false);
const visibleCardsList = useSelector(
(state: RootState) => state.ui.dashboardVisibleCards
);
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
const { monitors, summary } = useMonitorListController({
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "monitors",
rowsPerPageDefault: 100,
});
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
const countMap = new Map<MonitorTypeCount["type"], number>();
(monitors ?? []).forEach((m) =>
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
);
return [...countMap.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}, [monitors]);
const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
};
return (
<BasePage headerKey="dashboard">
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
<MonitorStatusCard summary={MOCK_SUMMARY} />
<MonitorsByTypeCard monitorsByType={MOCK_MONITORS_BY_TYPE} />
</Box>
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
size="small"
onClick={() => setEditOpen(true)}
>
{t("pages.dashboard.editCards")}
</Button>
</Stack>
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<div key={key}>{cardComponents[key]}</div>
))}
</Box>
</Stack>
<EditCardsModal
open={editOpen}
visibleCards={visibleCards}
onClose={() => setEditOpen(false)}
onSave={(next) => {
dispatch(setDashboardVisibleCards([...next]));
setEditOpen(false);
}}
/>
</BasePage>
);
};
-6
View File
@@ -1,6 +0,0 @@
import type { MonitorType } from "@/Types/Monitor";
export interface DashboardTypeCount {
type: MonitorType;
count: number;
}
+5
View File
@@ -192,6 +192,11 @@ export interface MonitorsSummary {
breachedMonitors: number;
}
export interface MonitorTypeCount {
type: MonitorType;
count: number;
}
export interface MonitorsWithChecksResponse {
count: number;
monitors: MonitorWithChecks[];
+7
View File
@@ -1911,6 +1911,7 @@
}
},
"dashboard": {
"noData": "No data available at this moment.",
"monitorStatus": {
"ofUp": "of {{total}} up"
},
@@ -1921,6 +1922,12 @@
"header": {
"title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention."
},
"editCards": "Edit cards",
"editCardsModal": {
"title": "Edit cards",
"description": "Select the cards to display on your dashboard.",
"done": "Done"
}
}
}
@@ -9,6 +9,7 @@ import type {
PageSpeedChecksResult,
UptimeChecksResult,
} from "@/domain/checks/check.type.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import { CheckFilter, DateRange } from "@/types/query.js";
@@ -43,7 +44,7 @@ export interface IChecksRepository {
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
findUnevaluatedByMonitorId(monitorId: string, pending: PendingCheck[]): Promise<Check[]>;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
// update
@@ -33,6 +33,7 @@ import {
getDockerTotalChecks,
getDockerUpChecks,
} from "@/domain/checks/check.docker.aggregation.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
const SERVICE_NAME = "ChecksRepository";
@@ -360,10 +361,23 @@ class MongoChecksRepository implements IChecksRepository {
};
};
findUnevaluatedByMonitorId = async (monitorId: string, since: number) => {
findUnevaluatedByMonitorId = async (monitorId: string, pending: PendingCheck[]) => {
if (pending.length === 0) return [];
const ids = pending.map((entry) => new mongoose.Types.ObjectId(entry.checkId));
// Bound the query to the pending time range so the monitorId + createdAt index is used
let from = Number.POSITIVE_INFINITY;
let to = Number.NEGATIVE_INFINITY;
for (const entry of pending) {
if (entry.createdAt < from) from = entry.createdAt;
if (entry.createdAt > to) to = entry.createdAt;
}
const docs = await CheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gt: new Date(since) },
createdAt: { $gte: new Date(from), $lte: new Date(to) },
_id: { $in: ids },
})
.sort({ createdAt: 1 })
.lean<CheckDocument[]>();
@@ -12,7 +12,6 @@ const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
toLastEvaluatedAt(check: Check): number;
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
getChecksByMonitor(params: {
@@ -55,10 +54,6 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
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;
+12
View File
@@ -47,6 +47,18 @@ const JobSchema = new Schema<JobDocument>(
default: null,
},
// Pending checks, inserted but not yet evaluated
pendingChecks: {
type: [
{
checkId: { type: String, required: true },
createdAt: { type: Number, required: true },
},
],
default: [],
_id: false,
},
// Observability
runCount: {
type: Number,
@@ -1,4 +1,4 @@
import { Job, JobSeed, JobType } from "@/domain/jobs/job.type.js";
import { Job, JobSeed, JobType, PendingCheck } from "@/domain/jobs/job.type.js";
export type JobPageQuery = {
page?: number;
@@ -32,9 +32,12 @@ export interface IJobsRepository {
recordOneShot(id: string, now: number): Promise<boolean>;
// ********************
// Hand off to evaluator // This is accomplished by creating an "evaluation" type job
// Hand off to evaluator // This is accomplished pushing newly inserted Check IDs onto the evaluation row
// ********************
upsertEvaluate(monitorId: string, now: number): Promise<boolean>;
upsertEvaluate(monitorId: string, pending: PendingCheck[], now: number): Promise<boolean>;
// Removes processed checks from the evaluate row. Returns false when this worker no longer holds the lease.
pullEvaluated(id: string, checkIds: string[]): Promise<boolean>;
// ********************
// Job CRUD
+33 -14
View File
@@ -1,6 +1,6 @@
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";
import { JobType, Job, JobSeed, BACKOFF_MS, LOCK_MS, PARKED, jobId, PendingCheck } from "@/domain/jobs/job.type.js";
// Cap for rearm interval, never want to wait more than 15s.
const REARM_JITTER_MAX_MS = 15_000;
@@ -18,6 +18,7 @@ class MongoJobsRepository implements IJobsRepository {
intervalMs: doc.intervalMs,
lockedBy: doc.lockedBy,
lockedUntil: doc.lockedUntil,
pendingChecks: doc.pendingChecks ?? [],
runCount: doc.runCount,
failCount: doc.failCount,
lastFinishedAt: doc.lastFinishedAt,
@@ -146,24 +147,27 @@ class MongoJobsRepository implements IJobsRepository {
_id: id,
lockedBy: this.workerId, // Only the worker that claimed can record success
},
{
$set: {
nextScheduledAt: PARKED,
lockedBy: null, // Remove lock
lockedUntil: null,
lastFinishedAt: now,
failCount: 0,
lastFailReason: null,
[
{
$set: {
// Re-arm if more checks were pushed while this run held the lease; otherwise park
nextScheduledAt: {
$cond: [{ $gt: [{ $size: { $ifNull: ["$pendingChecks", []] } }, 0] }, now, PARKED],
},
lockedBy: null, // Remove lock
lockedUntil: null,
lastFinishedAt: now,
failCount: 0,
lastFailReason: null,
runCount: { $add: ["$runCount", 1] },
},
},
$inc: {
runCount: 1, // Increment run count
},
}
]
);
return res.modifiedCount === 1;
};
upsertEvaluate = async (monitorId: string, now: number) => {
upsertEvaluate = async (monitorId: string, pending: PendingCheck[], now: number) => {
const filter = { type: "evaluate", refId: monitorId };
const update = {
$setOnInsert: {
@@ -181,6 +185,9 @@ class MongoJobsRepository implements IJobsRepository {
$min: {
nextScheduledAt: now, // no op if there is already a pending evaluation that is older
},
$addToSet: {
pendingChecks: { $each: pending }, // accumulate pending checks; a retried arm never duplicates an entry
},
};
try {
const res = await JobModel.updateOne(filter, update, { upsert: true });
@@ -195,6 +202,18 @@ class MongoJobsRepository implements IJobsRepository {
throw error;
}
};
// Returns whether this worker still holds the lease. false means another worker has claimed the row and the caller must stop.
pullEvaluated = async (id: string, checkIds: string[]) => {
if (checkIds.length === 0) return true;
const res = await JobModel.updateOne(
{ _id: id, lockedBy: this.workerId }, // Only the worker that claimed can pull checks
{ $pull: { pendingChecks: { checkId: { $in: checkIds } } } }
);
return res.matchedCount === 1;
};
upsertJob = async (job: JobSeed) => {
const res = await JobModel.updateOne(
{ _id: job.id },
+8
View File
@@ -4,6 +4,11 @@ export const LOCK_MS = 60_000;
export const BACKOFF_MS = 5_000;
export const PARKED = Number.MAX_SAFE_INTEGER;
export type PendingCheck = {
checkId: string;
createdAt: number; // epoch ms, copied from Check.createdAt
};
// Canonical _id scheme: monitor-bound rows are `${type}:${refId}`; global rows (refId null: cleanup, egress) are just the type.
export const jobId = (type: JobType, refId: string | null): string => (refId === null ? type : `${type}:${refId}`);
@@ -21,6 +26,9 @@ export type Job = {
lockedBy: string | null; // workerId holding the lease
lockedUntil: number | null; // epoch ms; lease expiry => reclaimable after a crash
// Evaluate these rows only. These are checks that are inserted but not yet evaluated
pendingChecks: PendingCheck[];
// observability (replaces what getMetrics/getJobs)
runCount: number;
failCount: number;
@@ -259,7 +259,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
const threshold = monitor.tempAlertThreshold;
if (maxTemp >= threshold) {
if (maxTemp > threshold) {
breaches.push({
metric: "temp",
currentValue: maxTemp,
+40 -6
View File
@@ -7,12 +7,14 @@ import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"
import { ICheckService } from "../domain/checks/check.service.js";
import { DockerLog } from "@/domain/docker/docker-log.type.js";
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
addDockerLogToBuffer(dockerLog: DockerLog): void;
ingestChecks(checks: Check[]): Promise<void>;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -34,6 +36,9 @@ export class BufferService implements IBufferService {
private dockerLogsService: IDockerLogsService;
private jobsRepository: IJobsRepository;
// Checks that are stored but whose evaluate row could not be armed. Retried on the next flush so they are never unevaluated.
private unarmed: Map<string, PendingCheck[]> = new Map();
constructor(
logger: ILogger,
checkService: ICheckService,
@@ -99,6 +104,38 @@ export class BufferService implements IBufferService {
}
}
ingestChecks = async (checks: Check[]) => {
if (checks.length > 0) await this.checksService.createChecks(checks);
// Group by monitor, starting from any entries a previous flush failed to arm
const byMonitor = this.unarmed;
this.unarmed = new Map();
for (const check of checks) {
const pending = byMonitor.get(check.metadata.monitorId) ?? [];
pending.push({ checkId: check.id, createdAt: new Date(check.createdAt).getTime() });
byMonitor.set(check.metadata.monitorId, pending);
}
if (byMonitor.size === 0) return;
const now = Date.now();
const ops: Promise<boolean>[] = [];
for (const [monitorId, pendingChecks] of byMonitor) {
ops.push(
this.jobsRepository.upsertEvaluate(monitorId, pendingChecks, now).catch((error: unknown) => {
// The checks are already stored, so a failed arm is retried on the next flush rather than dropped
this.unarmed.set(monitorId, pendingChecks);
this.logger.error({
message: `Could not arm evaluation for monitor ${monitorId}, retrying on next flush: ${error instanceof Error ? error.message : String(error)}`,
service: this.SERVICE_NAME,
method: "ingestChecks",
});
return false;
})
);
}
await Promise.all(ops);
};
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -121,8 +158,9 @@ export class BufferService implements IBufferService {
}
}, this.BUFFER_TIMEOUT);
}
async flushBuffer() {
if (this.buffer.length === 0) {
if (this.buffer.length === 0 && this.unarmed.size === 0) {
return;
}
// Take the batch first so a write that drains it can't be appended to mid-flush
@@ -134,11 +172,7 @@ export class BufferService implements IBufferService {
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)));
await this.ingestChecks(batch);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
+26 -4
View File
@@ -120,19 +120,41 @@ export class DBQueueWorker extends JobScheduler implements IQueueWorker {
// ********************
private runEvaluate = async (job: Job) => {
if (!job.refId) return;
if (!job.refId || job.pendingChecks.length === 0) return;
const pendingCheckIds = job.pendingChecks.map((entry) => entry.checkId);
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);
if (!monitor) {
await this.jobsRepository.pullEvaluated(job.id, pendingCheckIds);
return;
}
const checks = await this.checksRepository.findUnevaluatedByMonitorId(job.refId, job.pendingChecks);
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) });
const leaseHeld = await this.jobsRepository.pullEvaluated(job.id, [check.id]);
if (!leaseHeld) {
// Another worker has claimed this row and is evaluating the remaining ids; stop here so nothing is applied twice
this.logger.warn({ message: `Lost lease on ${job.id} after check ${check.id}, stopping`, service: SERVICE_NAME, method: "runEvaluate" });
return;
}
current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check
}
// Ids whose check was not returned (deleted by retention cleanup) would otherwise sit on the row forever
const found = new Set(checks.map((check) => check.id));
const missing = pendingCheckIds.filter((checkId) => !found.has(checkId));
if (missing.length > 0) {
this.logger.warn({
message: `Dropping ${missing.length} pending checks with no stored check: ${missing.join(", ")}`,
service: SERVICE_NAME,
method: "runEvaluate",
});
await this.jobsRepository.pullEvaluated(job.id, missing);
}
};
private runGeoCheck = async (job: Job) => {
@@ -0,0 +1,200 @@
import { describe, expect, it, jest, beforeAll, afterAll, beforeEach, afterEach } from "@jest/globals";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts";
import MongoJobsRepository from "../../src/domain/jobs/job.repository.mongo.ts";
import { CheckModel } from "../../src/domain/checks/check.model.ts";
import JobModel from "../../src/domain/jobs/job.model.ts";
import { CheckService } from "../../src/domain/checks/check.service.ts";
import { BufferService } from "../../src/service/bufferService.ts";
import { DBQueueWorker } from "../../src/worker/worker.db-queue.ts";
import { InMemoryMonitorsRepository } from "../helpers/InMemoryMonitorsRepository.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { Job } from "../../src/domain/jobs/job.type.ts";
import type { Check } from "../../src/domain/checks/check.type.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { ILogger } from "../../src/utils/logger.ts";
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
// Regression test for the dropped-check bug in the check-to-evaluate handoff.
//
// The evaluate step used to be driven by a timestamp cursor: the evaluator read checks with
// createdAt strictly after the monitor's lastEvaluatedAt and advanced that cursor to each
// check's createdAt. Each worker's BufferService flushes its own in-memory batch on its own
// timer, so with two processing workers a later check produced on worker B could be flushed and
// evaluated before an earlier check produced on worker A had been flushed. When A's batch landed,
// its check was older than the cursor and the evaluate query never returned it. The check existed
// in the collection but never reached the status window, stats, incidents or notifications.
//
// The evaluate row now carries the ids of every stored check, pushed at ingest and pulled as each
// is applied, so a late batch is always evaluated. This drives the real time-series query, the
// real jobs collection, the real buffer flush and the real DBQueueWorker evaluate path against an
// in-process mongod, so the handoff semantics under test are MongoDB's, not a mock's.
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
await CheckModel.createCollection(); // timeseries collections must exist before insert
await JobModel.init();
}, 120_000);
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
beforeEach(async () => {
await CheckModel.deleteMany({});
await JobModel.deleteMany({});
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const MONITOR_ID = new mongoose.Types.ObjectId().toString();
const TEAM_ID = new mongoose.Types.ObjectId().toString();
const T0 = Date.parse("2026-01-01T00:00:00.000Z");
const INTERVAL_MS = 30_000;
const makeMonitor = (): Monitor =>
({
id: MONITOR_ID,
teamId: TEAM_ID,
type: "http",
interval: INTERVAL_MS,
status: "up",
isActive: true,
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
recentChecks: [],
lastEvaluatedAt: 0,
}) as unknown as Monitor;
const makeStatusResponse = (): MonitorStatusResponse => ({
monitorId: MONITOR_ID,
teamId: TEAM_ID,
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
});
// Production stamps createdAt at the moment the probe ran, on the producing worker; pin it so
// the two workers' checks have a known order regardless of wall clock.
const producedAt = (check: Check, at: number): Check => {
const createdAt = new Date(at).toISOString();
return { ...check, createdAt, updatedAt: createdAt };
};
const stubSettingsService = { getSettings: () => ({ nodeEnv: "production" }) };
const stubGeoChecksService = { createGeoChecks: jest.fn<any>().mockResolvedValue([]) };
const stubDockerLogsService = { createDockerLogs: jest.fn<any>().mockResolvedValue(0) };
describe("Evaluate cursor with more than one processing worker", () => {
let logger: ILogger;
let monitorsRepository: InMemoryMonitorsRepository;
let checksRepository: MongoChecksRepository;
let checkService: CheckService;
let bufferA: BufferService;
let bufferB: BufferService;
let evaluatedCheckIds: string[];
let evaluator: DBQueueWorker;
let evaluatorJobsRepository: MongoJobsRepository;
// One BufferService per worker process, each flushing into the same collection and jobs table.
const createWorkerBuffer = (workerId: string) =>
new BufferService(
logger,
checkService,
stubGeoChecksService as any,
stubDockerLogsService as any,
stubSettingsService as any,
new MongoJobsRepository(workerId)
);
// Claim every due evaluate job and run it through the worker's real per-job path.
const runDueEvaluateJobs = async () => {
const jobs = await evaluatorJobsRepository.claimDueBatch("evaluate", 20, Date.now());
const runJob = (evaluator as unknown as { runJob: (job: Job) => Promise<void> }).runJob;
for (const job of jobs) await runJob(job);
return jobs.length;
};
beforeEach(() => {
logger = createMockLogger() as unknown as ILogger;
monitorsRepository = new InMemoryMonitorsRepository();
monitorsRepository.seed(makeMonitor());
checksRepository = new MongoChecksRepository(logger);
checkService = new CheckService(monitorsRepository, logger, checksRepository);
bufferA = createWorkerBuffer("worker-a");
bufferB = createWorkerBuffer("worker-b");
evaluatedCheckIds = [];
evaluatorJobsRepository = new MongoJobsRepository("worker-b");
evaluator = new DBQueueWorker({
logger,
isDbConnected: () => true,
jobsRepository: evaluatorJobsRepository,
monitorsRepository,
checksRepository,
checkService,
bufferService: bufferB,
checkProducer: { produce: jest.fn<any>() },
checkEvaluator: {
evaluate: async (status: MonitorStatusResponse, check: Check, monitor: Monitor) => {
evaluatedCheckIds.push(check.id);
return {
monitor,
status,
check,
statusChange: { monitor, statusChanged: false, prevStatus: monitor.status, code: 200, timestamp: 0 },
decision: {},
};
},
} as any,
geoCheckPipeline: { run: jest.fn<any>() },
dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) },
helper: {} as any,
queueWorkersRepository: {} as any,
queueMode: "worker",
queuePrimaryProcesses: true,
workerId: "worker-b",
});
});
afterEach(async () => {
await bufferA.shutdown();
await bufferB.shutdown();
});
it("evaluates every persisted check even when a later worker's batch flushes first", async () => {
// t=0: worker A holds the check job, probes, and buffers the result. Its flush timer has not fired.
const c1 = producedAt(checkService.toCheck(makeStatusResponse())!, T0);
bufferA.addToBuffer(c1);
// t=30s: worker B claims the next run of the same job, probes, and buffers the result.
const c2 = producedAt(checkService.toCheck(makeStatusResponse())!, T0 + INTERVAL_MS);
bufferB.addToBuffer(c2);
// t=45s: worker B's flush timer fires first. c2 is inserted and an evaluate job is queued.
await bufferB.flushBuffer();
expect(await runDueEvaluateJobs()).toBe(1);
expect(evaluatedCheckIds).toEqual([c2.id]);
// t=60s: worker A's flush timer fires. c1 is inserted and the evaluate job is pulled due again.
await bufferA.flushBuffer();
expect(await runDueEvaluateJobs()).toBe(1);
// Both checks are durably stored and visible in history.
const stored = await CheckModel.find({ "metadata.monitorId": new mongoose.Types.ObjectId(MONITOR_ID) }).lean();
expect(stored.map((doc) => String(doc._id)).sort()).toEqual([c1.id, c2.id].sort());
// Every stored check must reach the evaluator exactly once.
expect(evaluatedCheckIds).toHaveLength(2);
expect(evaluatedCheckIds).toContain(c1.id);
});
});
+135 -14
View File
@@ -3,7 +3,7 @@ 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, PARKED, type Job, type JobType } from "../../src/domain/jobs/job.type.ts";
import { BACKOFF_MS, LOCK_MS, PARKED, type Job, type JobType, type PendingCheck } from "../../src/domain/jobs/job.type.ts";
// ── Real-Mongo harness ─────────────────────────────────────────────────────────
// The headline guarantee of the queue — "exactly one claimer wins" — IS MongoDB's
@@ -58,6 +58,9 @@ const ownedBy = (r: MongoJobsRepository) => (r as unknown as { workerId: string
const WORKER_ID = "worker-under-test";
// Pending entries as ingest pushes them: one per inserted check, stamped with the check's createdAt.
const pending = (...checkIds: string[]): PendingCheck[] => checkIds.map((checkId, i) => ({ checkId, createdAt: NOW + i }));
describe("MongoJobsRepository", () => {
let repo: MongoJobsRepository;
@@ -239,6 +242,23 @@ describe("MongoJobsRepository", () => {
expect(row?.failCount).toBe(2);
expect(row?.lastFailReason).toBe("timeout");
});
it("re-arms instead of parking when checks were pushed while the run held the lease", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: ownedBy(repo),
lockedUntil: NOW + LOCK_MS,
pendingChecks: pending("c-late"), // arrived mid-run, not yet pulled
});
expect(await repo.recordOneShot("evaluate:mon-1", NOW + 1_000)).toBe(true);
const row = await readRow("evaluate:mon-1");
expect(row?.nextScheduledAt).toBe(NOW + 1_000); // due now, not PARKED
expect(row?.lockedBy).toBeNull();
expect(row?.runCount).toBe(1);
});
});
// ── renewLocks (lease renewal) ───────────────────────────────────────────────
@@ -325,10 +345,10 @@ describe("MongoJobsRepository", () => {
});
});
// ── upsertEvaluate (handoff + $min coalescing) ───────────────────────────────
// ── upsertEvaluate (handoff: $push ids + $min due time) ──────────────────────
describe("upsertEvaluate", () => {
it("creates a single evaluate row keyed by monitor", async () => {
await repo.upsertEvaluate("mon-1", NOW);
it("creates a single evaluate row keyed by monitor carrying the pushed ids", async () => {
await repo.upsertEvaluate("mon-1", pending("c1", "c2"), NOW);
const row = await readRow("evaluate:mon-1");
expect(row?.type).toBe("evaluate");
@@ -336,11 +356,12 @@ describe("MongoJobsRepository", () => {
expect(row?.nextScheduledAt).toBe(NOW);
expect(row?.intervalMs).toBeNull();
expect(row?.isActive).toBe(true);
expect(row?.pendingChecks).toEqual(pending("c1", "c2"));
});
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);
await repo.upsertEvaluate("mon-1", pending("c1"), NOW);
await repo.upsertEvaluate("mon-1", pending("c2"), NOW + 9_000);
const rows = await JobModel.find({ type: "evaluate", refId: "mon-1" }).lean<JobDocument[]>();
expect(rows).toHaveLength(1);
@@ -348,19 +369,117 @@ describe("MongoJobsRepository", () => {
});
it("lowers runAt when an earlier evaluation arrives ($min)", async () => {
await repo.upsertEvaluate("mon-1", NOW + 9_000);
await repo.upsertEvaluate("mon-1", NOW);
await repo.upsertEvaluate("mon-1", pending("c1"), NOW + 9_000);
await repo.upsertEvaluate("mon-1", pending("c2"), 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)));
it("never duplicates an entry when the same batch is armed twice (retry after a failed arm)", async () => {
await repo.upsertEvaluate("mon-1", pending("c1", "c2"), NOW);
await repo.upsertEvaluate("mon-1", pending("c1", "c2"), NOW + 1_000);
const count = await JobModel.countDocuments({ type: "evaluate", refId: "mon-1" });
expect(count).toBe(1);
const row = await readRow("evaluate:mon-1");
expect(row?.pendingChecks).toEqual(pending("c1", "c2"));
});
it("accumulates ids across flushes so a late batch is never lost", async () => {
await repo.upsertEvaluate("mon-1", pending("c2"), NOW); // later check, flushed first
await repo.upsertEvaluate("mon-1", pending("c1"), NOW + 15_000); // earlier check, flushed later
const row = await readRow("evaluate:mon-1");
expect(row?.pendingChecks.map((entry) => entry.checkId)).toEqual(["c2", "c1"]);
});
it("keeps pushing onto a row that is currently leased by a worker", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: "other-worker",
lockedUntil: NOW + LOCK_MS,
pendingChecks: pending("c1"),
});
await repo.upsertEvaluate("mon-1", pending("c2"), NOW);
const row = await readRow("evaluate:mon-1");
expect(row?.lockedBy).toBe("other-worker"); // lease untouched
expect(row?.pendingChecks.map((entry) => entry.checkId)).toEqual(["c1", "c2"]);
});
it("concurrent upserts for one monitor produce exactly one row holding every id", async () => {
const workers = Array.from({ length: 20 }, (_, i) => new MongoJobsRepository(`worker-${i}`));
await Promise.all(workers.map((w, i) => w.upsertEvaluate("mon-1", pending(`c${i}`), NOW)));
const rows = await JobModel.find({ type: "evaluate", refId: "mon-1" }).lean<JobDocument[]>();
expect(rows).toHaveLength(1);
expect(rows[0].pendingChecks).toHaveLength(20);
});
});
// ── pullEvaluated (evaluator removes applied ids) ───────────────────────────
describe("pullEvaluated", () => {
it("removes only the listed ids and leaves the rest", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: ownedBy(repo),
lockedUntil: NOW + LOCK_MS,
pendingChecks: pending("c1", "c2", "c3"),
});
expect(await repo.pullEvaluated("evaluate:mon-1", ["c1", "c3"])).toBe(true);
const row = await readRow("evaluate:mon-1");
expect(row?.pendingChecks.map((entry) => entry.checkId)).toEqual(["c2"]);
});
it("is fenced on the lease: another worker's row is untouched", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: "other-worker",
lockedUntil: NOW + LOCK_MS,
pendingChecks: pending("c1"),
});
expect(await repo.pullEvaluated("evaluate:mon-1", ["c1"])).toBe(false);
const row = await readRow("evaluate:mon-1");
expect(row?.pendingChecks.map((entry) => entry.checkId)).toEqual(["c1"]);
});
it("reports the lease as held even when the ids were already pulled", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: ownedBy(repo),
lockedUntil: NOW + LOCK_MS,
pendingChecks: [],
});
expect(await repo.pullEvaluated("evaluate:mon-1", ["c1"])).toBe(true);
});
it("is a no-op for an empty id list", async () => {
await seedJob({
_id: "evaluate:mon-1",
type: "evaluate",
intervalMs: null,
lockedBy: ownedBy(repo),
lockedUntil: NOW + LOCK_MS,
pendingChecks: pending("c1"),
});
expect(await repo.pullEvaluated("evaluate:mon-1", [])).toBe(true);
const row = await readRow("evaluate:mon-1");
expect(row?.pendingChecks).toHaveLength(1);
});
});
@@ -376,6 +495,7 @@ describe("MongoJobsRepository", () => {
intervalMs: 60_000,
lockedBy: null,
lockedUntil: null,
pendingChecks: [],
runCount: 0,
failCount: 0,
lastFinishedAt: null,
@@ -407,6 +527,7 @@ describe("MongoJobsRepository", () => {
intervalMs: 86_400_000,
lockedBy: null,
lockedUntil: null,
pendingChecks: [],
runCount: 0,
failCount: 0,
lastFinishedAt: null,
@@ -487,7 +608,7 @@ describe("MongoJobsRepository", () => {
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.upsertEvaluate("mon-1", pending("c1"), NOW);
await repo.deleteById("mon-1");
@@ -45,6 +45,8 @@ const makeCheck = (overrides?: Partial<Check>): Check =>
statusCode: 200,
responseTime: 100,
message: "OK",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
}) as Check;
@@ -392,9 +394,44 @@ describe("BufferService", () => {
await service.flushBuffer();
const createdAt = Date.parse("2026-01-01T00:00:00.000Z");
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2);
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-1", expect.any(Number));
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-2", expect.any(Number));
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith(
"mon-1",
[
{ checkId: "c1", createdAt },
{ checkId: "c2", createdAt },
],
expect.any(Number)
);
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-2", [{ checkId: "c3", createdAt }], expect.any(Number));
});
it("retries a failed arm on the next flush without re-storing the checks", async () => {
const { service, checkService, jobsRepository, logger } = createService();
(jobsRepository.upsertEvaluate as jest.Mock).mockRejectedValueOnce(new Error("arm failed"));
service.addToBuffer(makeCheck({ id: "c1" }));
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "ingestChecks" }));
await service.flushBuffer(); // buffer is empty, only the retry runs
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2);
expect(jobsRepository.upsertEvaluate).toHaveBeenLastCalledWith("mon-1", [{ checkId: "c1", createdAt: expect.any(Number) }], expect.any(Number));
await service.flushBuffer(); // nothing left to retry
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2);
});
it("ingestChecks with an empty batch neither writes nor arms", async () => {
const { service, checkService, jobsRepository } = createService();
await service.ingestChecks([]);
expect(checkService.createChecks).not.toHaveBeenCalled();
expect(jobsRepository.upsertEvaluate).not.toHaveBeenCalled();
});
it("does not arm the evaluate stage when the check write fails", async () => {
@@ -56,6 +56,7 @@ const makeJob = (overrides?: Partial<Job>): Job => ({
intervalMs: 60000,
lockedBy: null,
lockedUntil: null,
pendingChecks: [],
runCount: 0,
failCount: 0,
lastFinishedAt: null,
@@ -63,12 +64,22 @@ const makeJob = (overrides?: Partial<Job>): Job => ({
...overrides,
});
// An evaluate row as ingest leaves it: one pending entry per stored check
const makeEvaluateJob = (...checkIds: string[]): Job =>
makeJob({
id: "evaluate:m1",
type: "evaluate",
intervalMs: null,
pendingChecks: checkIds.map((checkId, i) => ({ checkId, createdAt: 1000 + i })),
});
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),
pullEvaluated: jest.fn<any>().mockResolvedValue(true),
recordSuccess: jest.fn<any>().mockResolvedValue(true),
recordFailure: jest.fn<any>().mockResolvedValue(true),
recordOneShot: jest.fn<any>().mockResolvedValue(true),
@@ -92,7 +103,6 @@ const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses
};
const checkService = {
toStatusResponse: jest.fn<any>().mockReturnValue({ status: "up" }),
toLastEvaluatedAt: jest.fn<any>().mockReturnValue(12345),
};
const bufferService = {
addToBuffer: jest.fn<any>(),
@@ -369,27 +379,78 @@ describe("DBQueueWorker", () => {
});
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 job = makeEvaluateJob("c1");
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 });
it("evaluate job loads the row's pending checks, dispatches each, and pulls its id after dispatch", async () => {
const job = makeEvaluateJob("c1");
const checksRepository = { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }]) };
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checksRepository },
});
expect(checksRepository.findUnevaluatedByMonitorId).toHaveBeenCalledWith("m1", job.pendingChecks);
expect(mocks.checkEvaluator.evaluate).toHaveBeenCalled();
expect(mocks.dispatcher.dispatch).toHaveBeenCalled();
expect(mocks.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { lastEvaluatedAt: 12345 });
expect(mocks.jobsRepository.pullEvaluated).toHaveBeenCalledWith(job.id, ["c1"]);
// The monitor cursor is gone: nothing writes lastEvaluatedAt
expect(mocks.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("evaluate job with no pending checks does nothing", async () => {
const job = makeEvaluateJob();
const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } });
expect(mocks.monitorsRepository.findByIdLean).not.toHaveBeenCalled();
expect(mocks.checksRepository.findUnevaluatedByMonitorId).not.toHaveBeenCalled();
expect(mocks.jobsRepository.recordOneShot).toHaveBeenCalledWith(job.id, expect.any(Number));
});
it("evaluate job for a deleted monitor drops every pending id", async () => {
const job = makeEvaluateJob("c1", "c2");
const monitorsRepository = { findByIdLean: jest.fn<any>().mockResolvedValue(null), updateById: jest.fn<any>() };
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, monitorsRepository },
});
expect(mocks.checkEvaluator.evaluate).not.toHaveBeenCalled();
expect(mocks.jobsRepository.pullEvaluated).toHaveBeenCalledWith(job.id, ["c1", "c2"]);
});
it("evaluate job stops applying checks once the lease is lost so a second claimer does not double-apply", async () => {
const job = makeEvaluateJob("c1", "c2", "c3");
const checksRepository = { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }, { id: "c2" }, { id: "c3" }]) };
const jobsRepository = {
...createWorker().mocks.jobsRepository,
claimDueBatch: claimOnce(job),
pullEvaluated: jest.fn<any>().mockResolvedValueOnce(true).mockResolvedValueOnce(false), // lease lost after c2
};
const { mocks } = await start({ mocks: { jobsRepository, checksRepository } });
expect(mocks.checkEvaluator.evaluate).toHaveBeenCalledTimes(2);
expect(mocks.dispatcher.dispatch).toHaveBeenCalledTimes(2);
expect(mocks.jobsRepository.pullEvaluated).toHaveBeenCalledTimes(2); // no trailing sweep either
expect(mocks.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "runEvaluate" }));
});
it("evaluate job pulls ids whose check no longer exists so they do not sit on the row forever", async () => {
const job = makeEvaluateJob("c1", "c-gone");
const checksRepository = { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }]) }; // c-gone was cleaned up
const { mocks } = await start({
mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checksRepository },
});
expect(mocks.checkEvaluator.evaluate).toHaveBeenCalledTimes(1);
expect(mocks.jobsRepository.pullEvaluated).toHaveBeenCalledWith(job.id, ["c1"]);
expect(mocks.jobsRepository.pullEvaluated).toHaveBeenCalledWith(job.id, ["c-gone"]);
});
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 job = makeEvaluateJob("c1", "c2");
const initialMonitor = makeMonitor();
const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1
const monitorsRepository = {
@@ -685,6 +685,22 @@ describe("NotificationMessageBuilder", () => {
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
// A temperature exactly AT the threshold must not report a breach here, matching
// StatusService.computeHardwareStatus's own strict `>` comparison (cpu/memory/disk
// already agree with this file on `>`; temp alone used to diverge with `>=`, so an
// incident-driving check at exactly the threshold never fired but the notification
// content for a breach some OTHER metric triggered could still list temp as breaching).
it("does not report temperature breach when exactly at threshold", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [80] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
it("skips temperature check when tempAlertThreshold is undefined", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined });
const response = makeStatusResponse({