From c7ef412d7ab855e6bdef5549482b5696692cc21d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 16 Apr 2026 10:41:00 -0700 Subject: [PATCH 1/5] atomic update --- .../monitors/IMonitorsRepository.ts | 10 +- .../monitors/MongoMonitorsRepository.ts | 25 ++++ .../service/infrastructure/statusService.ts | 119 ++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 8c5f63e7d..130a44ce5 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -1,4 +1,4 @@ -import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js"; +import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; export interface TeamQueryConfig { limit?: number; @@ -29,6 +29,14 @@ export interface IMonitorsRepository { // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; + updateStatusWindowAndChecks( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number + ): Promise; togglePauseById(monitorId: string, teamId: string): Promise; // delete deleteById(monitorId: string, teamId: string): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d759448..5b0549677 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -182,6 +182,31 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(updatedMonitor); }; + updateStatusWindowAndChecks = async ( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number + ): Promise => { + const updatedMonitor = await MonitorModel.findOneAndUpdate( + { _id: monitorId, teamId }, + { + $push: { + statusWindow: { $each: [status], $slice: -windowSize }, + recentChecks: { $each: [checkSnapshot], $slice: -maxRecentChecks }, + }, + }, + { returnDocument: "after" } + ); + + if (!updatedMonitor) { + throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 }); + } + return this.toEntity(updatedMonitor); + }; + togglePauseById = async (monitorId: string, teamId: string) => { const monitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index fa57117dc..edf432b17 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -217,6 +217,125 @@ export class StatusService implements IStatusService { return { nextStatus, transitioned, breaches, nextCounters }; }; + private updateStatusWindowAndRecentChecks = async (monitor: Monitor, check: Check) => { + const checkSnapshot = this.toCheckSnapshot(check); + return await this.monitorsRepository.updateStatusWindowAndChecks( + monitor.id, + monitor.teamId, + check.status, + checkSnapshot, + monitor.statusWindowSize, + MAX_RECENT_CHECKS + ); + }; + + newUpdateMonitorStatus = async ( + statusResponse: MonitorStatusResponse< + | PingStatusPayload + | HttpStatusPayload + | PageSpeedStatusPayload + | HardwareStatusPayload + | DockerStatusPayload + | PortStatusPayload + | GameStatusPayload + | GrpcStatusPayload + | undefined + >, + check: Check + ): Promise => { + try { + const { monitorId, teamId, status, code } = statusResponse; + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + + // Update running stats + await this.tryUpdateRunningStats(monitor, statusResponse); + const updatedMonitor = await this.updateStatusWindowAndRecentChecks(monitor, check); + const prevStatus = updatedMonitor.status; + + // Return early if not enough data points + if (updatedMonitor.statusWindow.length < updatedMonitor.statusWindowSize) { + updatedMonitor.status = status === true ? "up" : "down"; + const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, { status: updatedMonitor.status }); + return { + monitor: updated, + statusChanged: false, + prevStatus, + code, + timestamp: Date.now(), + }; + } + + // With a full window, a single raw check must not change UNLESS we are initializing. Otherwise, only the sliding-window threshold can trigger a transition. + let newStatus: MonitorStatus; + if (updatedMonitor.status === "initializing") { + newStatus = status === true ? "up" : "down"; + } else { + newStatus = updatedMonitor.status; + } + + let statusChanged = false; + + // First evaluate reachability-based status changes, which apply to all monitor types and take precedence over hardware breaches. + const reachabilityResult = this.computeReachability(newStatus, updatedMonitor.statusWindow, updatedMonitor.statusWindowThreshold); + if (reachabilityResult.transitioned) { + newStatus = reachabilityResult.nextStatus; + statusChanged = true; + } + + // Evaluate hardware threshold breaches (only for hardware monitors with metrics payload) + let thresholdBreaches: HardwareBreaches | undefined; + const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined; + if (updatedMonitor.type === "hardware" && hardwarePayload?.data) { + const hardware = this.computeHardwareStatus({ + currentStatus: newStatus, + reachabilityDown: newStatus === "down", + metrics: hardwarePayload.data, + thresholds: { + cpu: updatedMonitor.cpuAlertThreshold, + memory: updatedMonitor.memoryAlertThreshold, + disk: updatedMonitor.diskAlertThreshold, + temp: updatedMonitor.tempAlertThreshold, + }, + counters: { + cpu: updatedMonitor.cpuAlertCounter, + memory: updatedMonitor.memoryAlertCounter, + disk: updatedMonitor.diskAlertCounter, + temp: updatedMonitor.tempAlertCounter, + }, + }); + + updatedMonitor.cpuAlertCounter = hardware.nextCounters.cpu; + updatedMonitor.memoryAlertCounter = hardware.nextCounters.memory; + updatedMonitor.diskAlertCounter = hardware.nextCounters.disk; + updatedMonitor.tempAlertCounter = hardware.nextCounters.temp; + thresholdBreaches = hardware.breaches; + if (hardware.transitioned) { + newStatus = hardware.nextStatus; + statusChanged = true; + } + } + + // Apply the final status + updatedMonitor.status = newStatus; + + const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, updatedMonitor); + + return { + monitor: updated, + statusChanged, + prevStatus, + code, + timestamp: new Date().getTime(), + thresholdBreaches, + }; + } catch (error: unknown) { + throw new AppError({ + message: `Failed to update monitor with id ${check.metadata.monitorId} with status: ${error instanceof Error ? error.message : "Unknown error"}`, + service: SERVICE_NAME, + method: "updateMonitorStatus", + }); + } + }; updateMonitorStatus = async ( statusResponse: MonitorStatusResponse< | PingStatusPayload From 8f55d966f9d04cab9e9b07fdbbcb8e9bfbe89f40 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 16 Apr 2026 10:50:30 -0700 Subject: [PATCH 2/5] rename methods, remove unused helpers --- .../service/infrastructure/statusService.ts | 141 ++---------------- 1 file changed, 10 insertions(+), 131 deletions(-) diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index edf432b17..5ae2a48ed 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -102,14 +102,6 @@ export class StatusService implements IStatusService { } }; - private appendToWindow = (monitor: Monitor, status: boolean) => { - monitor.statusWindow = monitor.statusWindow || []; - monitor.statusWindow.push(status); - while (monitor.statusWindow.length > monitor.statusWindowSize) { - monitor.statusWindow.shift(); - } - }; - private toCheckSnapshot = (check: Check): CheckSnapshot => { return { id: check.id, @@ -134,15 +126,6 @@ export class StatusService implements IStatusService { }; }; - private appendToRecentChecks = (monitor: Monitor, check: Check) => { - monitor.recentChecks = monitor.recentChecks || []; - const checkSnapshot = this.toCheckSnapshot(check); - monitor.recentChecks.push(checkSnapshot); - while (monitor.recentChecks.length > MAX_RECENT_CHECKS) { - monitor.recentChecks.shift(); - } - }; - private computeReachability = ( currentStatus: MonitorStatus, window: Array, @@ -229,7 +212,7 @@ export class StatusService implements IStatusService { ); }; - newUpdateMonitorStatus = async ( + updateMonitorStatus = async ( statusResponse: MonitorStatusResponse< | PingStatusPayload | HttpStatusPayload @@ -315,121 +298,17 @@ export class StatusService implements IStatusService { } } - // Apply the final status - updatedMonitor.status = newStatus; - - const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, updatedMonitor); - - return { - monitor: updated, - statusChanged, - prevStatus, - code, - timestamp: new Date().getTime(), - thresholdBreaches, - }; - } catch (error: unknown) { - throw new AppError({ - message: `Failed to update monitor with id ${check.metadata.monitorId} with status: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "updateMonitorStatus", - }); - } - }; - updateMonitorStatus = async ( - statusResponse: MonitorStatusResponse< - | PingStatusPayload - | HttpStatusPayload - | PageSpeedStatusPayload - | HardwareStatusPayload - | DockerStatusPayload - | PortStatusPayload - | GameStatusPayload - | GrpcStatusPayload - | undefined - >, - check: Check - ): Promise => { - try { - const { monitorId, teamId, status, code } = statusResponse; - const monitor = await this.monitorsRepository.findById(monitorId, teamId); - - // Update running stats - await this.tryUpdateRunningStats(monitor, statusResponse); - - // Update the sliding window and recent checks - this.appendToWindow(monitor, status); - this.appendToRecentChecks(monitor, check); - - const prevStatus = monitor.status; - - // Return early if not enough data points - if (monitor.statusWindow.length < monitor.statusWindowSize) { - monitor.status = status === true ? "up" : "down"; - const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); - return { - monitor: updated, - statusChanged: false, - prevStatus, - code, - timestamp: Date.now(), - }; + // Apply the final status — only write fields computed by application logic, + // not statusWindow/recentChecks which are owned by the atomic push. + const patch: Partial = { status: newStatus }; + if (updatedMonitor.type === "hardware" && hardwarePayload?.data) { + patch.cpuAlertCounter = updatedMonitor.cpuAlertCounter; + patch.memoryAlertCounter = updatedMonitor.memoryAlertCounter; + patch.diskAlertCounter = updatedMonitor.diskAlertCounter; + patch.tempAlertCounter = updatedMonitor.tempAlertCounter; } - // With a full window, a single raw check must not change UNLESS we are initializing. Otherwise, only the sliding-window threshold can trigger a transition. - let newStatus: MonitorStatus; - if (monitor.status === "initializing") { - newStatus = status === true ? "up" : "down"; - } else { - newStatus = monitor.status; - } - - let statusChanged = false; - - // First evaluate reachability-based status changes, which apply to all monitor types and take precedence over hardware breaches. - const reachabilityResult = this.computeReachability(newStatus, monitor.statusWindow, monitor.statusWindowThreshold); - if (reachabilityResult.transitioned) { - newStatus = reachabilityResult.nextStatus; - statusChanged = true; - } - - // Evaluate hardware threshold breaches (only for hardware monitors with metrics payload) - let thresholdBreaches: HardwareBreaches | undefined; - const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined; - if (monitor.type === "hardware" && hardwarePayload?.data) { - const hardware = this.computeHardwareStatus({ - currentStatus: newStatus, - reachabilityDown: newStatus === "down", - metrics: hardwarePayload.data, - thresholds: { - cpu: monitor.cpuAlertThreshold, - memory: monitor.memoryAlertThreshold, - disk: monitor.diskAlertThreshold, - temp: monitor.tempAlertThreshold, - }, - counters: { - cpu: monitor.cpuAlertCounter, - memory: monitor.memoryAlertCounter, - disk: monitor.diskAlertCounter, - temp: monitor.tempAlertCounter, - }, - }); - - monitor.cpuAlertCounter = hardware.nextCounters.cpu; - monitor.memoryAlertCounter = hardware.nextCounters.memory; - monitor.diskAlertCounter = hardware.nextCounters.disk; - monitor.tempAlertCounter = hardware.nextCounters.temp; - thresholdBreaches = hardware.breaches; - if (hardware.transitioned) { - newStatus = hardware.nextStatus; - statusChanged = true; - } - } - - // Apply the final status - monitor.status = newStatus; - - const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); + const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, patch); return { monitor: updated, From 24d7ead1d18c78eba651d604152dd658ff32b9bf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 16 Apr 2026 10:59:30 -0700 Subject: [PATCH 3/5] use a projected window instead of read/write --- .../monitors/IMonitorsRepository.ts | 3 +- .../monitors/MongoMonitorsRepository.ts | 4 +- .../service/infrastructure/statusService.ts | 102 ++++++++++-------- 3 files changed, 61 insertions(+), 48 deletions(-) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 130a44ce5..941556589 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -35,7 +35,8 @@ export interface IMonitorsRepository { status: boolean, checkSnapshot: CheckSnapshot, windowSize: number, - maxRecentChecks: number + maxRecentChecks: number, + statusPatch?: Partial ): Promise; togglePauseById(monitorId: string, teamId: string): Promise; // delete diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 5b0549677..bd6d506d5 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -188,7 +188,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { status: boolean, checkSnapshot: CheckSnapshot, windowSize: number, - maxRecentChecks: number + maxRecentChecks: number, + statusPatch?: Partial ): Promise => { const updatedMonitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, @@ -197,6 +198,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindow: { $each: [status], $slice: -windowSize }, recentChecks: { $each: [checkSnapshot], $slice: -maxRecentChecks }, }, + ...(statusPatch && { $set: statusPatch }), }, { returnDocument: "after" } ); diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 5ae2a48ed..a21fa293f 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -200,18 +200,6 @@ export class StatusService implements IStatusService { return { nextStatus, transitioned, breaches, nextCounters }; }; - private updateStatusWindowAndRecentChecks = async (monitor: Monitor, check: Check) => { - const checkSnapshot = this.toCheckSnapshot(check); - return await this.monitorsRepository.updateStatusWindowAndChecks( - monitor.id, - monitor.teamId, - check.status, - checkSnapshot, - monitor.statusWindowSize, - MAX_RECENT_CHECKS - ); - }; - updateMonitorStatus = async ( statusResponse: MonitorStatusResponse< | PingStatusPayload @@ -232,13 +220,32 @@ export class StatusService implements IStatusService { // Update running stats await this.tryUpdateRunningStats(monitor, statusResponse); - const updatedMonitor = await this.updateStatusWindowAndRecentChecks(monitor, check); - const prevStatus = updatedMonitor.status; - // Return early if not enough data points - if (updatedMonitor.statusWindow.length < updatedMonitor.statusWindowSize) { - updatedMonitor.status = status === true ? "up" : "down"; - const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, { status: updatedMonitor.status }); + const prevStatus = monitor.status; + const checkSnapshot = this.toCheckSnapshot(check); + + // Project the window as it will look after updating DB + // This is done because we need the updated status window to compute new status, but we don't + // want an an extra DB write just to get the window. + const projectedWindow = [...(monitor.statusWindow || []), check.status].slice(-monitor.statusWindowSize); + + // Build the status patch — computed against the projected window + const patch: Partial = {}; + + // Not enough data points yet + if (projectedWindow.length < monitor.statusWindowSize) { + patch.status = status === true ? "up" : "down"; + + const updated = await this.monitorsRepository.updateStatusWindowAndChecks( + monitor.id, + monitor.teamId, + check.status, + checkSnapshot, + monitor.statusWindowSize, + MAX_RECENT_CHECKS, + patch + ); + return { monitor: updated, statusChanged: false, @@ -248,18 +255,20 @@ export class StatusService implements IStatusService { }; } - // With a full window, a single raw check must not change UNLESS we are initializing. Otherwise, only the sliding-window threshold can trigger a transition. + // With a full window, a single raw check must not change UNLESS we are initializing. + // Otherwise, only the sliding-window threshold can trigger a transition. let newStatus: MonitorStatus; - if (updatedMonitor.status === "initializing") { + if (monitor.status === "initializing") { newStatus = status === true ? "up" : "down"; } else { - newStatus = updatedMonitor.status; + newStatus = monitor.status; } let statusChanged = false; - // First evaluate reachability-based status changes, which apply to all monitor types and take precedence over hardware breaches. - const reachabilityResult = this.computeReachability(newStatus, updatedMonitor.statusWindow, updatedMonitor.statusWindowThreshold); + // First evaluate reachability-based 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) { newStatus = reachabilityResult.nextStatus; statusChanged = true; @@ -268,29 +277,29 @@ export class StatusService implements IStatusService { // Evaluate hardware threshold breaches (only for hardware monitors with metrics payload) let thresholdBreaches: HardwareBreaches | undefined; const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined; - if (updatedMonitor.type === "hardware" && hardwarePayload?.data) { + if (monitor.type === "hardware" && hardwarePayload?.data) { const hardware = this.computeHardwareStatus({ currentStatus: newStatus, reachabilityDown: newStatus === "down", metrics: hardwarePayload.data, thresholds: { - cpu: updatedMonitor.cpuAlertThreshold, - memory: updatedMonitor.memoryAlertThreshold, - disk: updatedMonitor.diskAlertThreshold, - temp: updatedMonitor.tempAlertThreshold, + cpu: monitor.cpuAlertThreshold, + memory: monitor.memoryAlertThreshold, + disk: monitor.diskAlertThreshold, + temp: monitor.tempAlertThreshold, }, counters: { - cpu: updatedMonitor.cpuAlertCounter, - memory: updatedMonitor.memoryAlertCounter, - disk: updatedMonitor.diskAlertCounter, - temp: updatedMonitor.tempAlertCounter, + cpu: monitor.cpuAlertCounter, + memory: monitor.memoryAlertCounter, + disk: monitor.diskAlertCounter, + temp: monitor.tempAlertCounter, }, }); - updatedMonitor.cpuAlertCounter = hardware.nextCounters.cpu; - updatedMonitor.memoryAlertCounter = hardware.nextCounters.memory; - updatedMonitor.diskAlertCounter = hardware.nextCounters.disk; - updatedMonitor.tempAlertCounter = hardware.nextCounters.temp; + patch.cpuAlertCounter = hardware.nextCounters.cpu; + patch.memoryAlertCounter = hardware.nextCounters.memory; + patch.diskAlertCounter = hardware.nextCounters.disk; + patch.tempAlertCounter = hardware.nextCounters.temp; thresholdBreaches = hardware.breaches; if (hardware.transitioned) { newStatus = hardware.nextStatus; @@ -298,17 +307,18 @@ export class StatusService implements IStatusService { } } - // Apply the final status — only write fields computed by application logic, - // not statusWindow/recentChecks which are owned by the atomic push. - const patch: Partial = { status: newStatus }; - if (updatedMonitor.type === "hardware" && hardwarePayload?.data) { - patch.cpuAlertCounter = updatedMonitor.cpuAlertCounter; - patch.memoryAlertCounter = updatedMonitor.memoryAlertCounter; - patch.diskAlertCounter = updatedMonitor.diskAlertCounter; - patch.tempAlertCounter = updatedMonitor.tempAlertCounter; - } + patch.status = newStatus; - const updated = await this.monitorsRepository.updateById(updatedMonitor.id, updatedMonitor.teamId, patch); + // Single atomic write: push arrays + set status/counters + const updated = await this.monitorsRepository.updateStatusWindowAndChecks( + monitor.id, + monitor.teamId, + check.status, + checkSnapshot, + monitor.statusWindowSize, + MAX_RECENT_CHECKS, + patch + ); return { monitor: updated, From 7547d1db2e80ace8f7e1917d3c46ffb13a357792 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 16 Apr 2026 10:59:43 -0700 Subject: [PATCH 4/5] update tests --- .../helpers/InMemoryMonitorsRepository.ts | 33 ++++++- .../test/unit/services/statusService.test.ts | 85 +++++++++++++++---- 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index 88b146914..a151ee41e 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -1,5 +1,5 @@ import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts"; -import type { Monitor, MonitorsSummary } from "../../src/types/index.ts"; +import type { Monitor, MonitorsSummary, CheckSnapshot } from "../../src/types/index.ts"; export class InMemoryMonitorsRepository implements IMonitorsRepository { private monitors: Monitor[] = []; @@ -49,6 +49,37 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return { ...updated }; } + async updateStatusWindowAndChecks( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number, + statusPatch?: Partial + ): Promise { + const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); + if (index === -1) { + throw new Error(`Monitor ${monitorId} not found`); + } + const monitor = this.monitors[index]; + monitor.statusWindow = monitor.statusWindow || []; + monitor.statusWindow.push(status); + while (monitor.statusWindow.length > windowSize) { + monitor.statusWindow.shift(); + } + monitor.recentChecks = monitor.recentChecks || []; + monitor.recentChecks.push(checkSnapshot); + while (monitor.recentChecks.length > maxRecentChecks) { + monitor.recentChecks.shift(); + } + if (statusPatch) { + Object.assign(monitor, statusPatch); + } + this.monitors[index] = monitor; + return { ...monitor }; + } + async togglePauseById(monitorId: string, teamId: string): Promise { const monitor = await this.findById(monitorId, teamId); const newStatus = monitor.status === "paused" ? "up" : "paused"; diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index 4e6ceb323..6bdfbe312 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -11,11 +11,49 @@ const createBuffer = (): jest.Mocked> => ({ addToBuffer: jest.fn(), }); -const createMonitorsRepo = () => - ({ - findById: jest.fn(), +const createMonitorsRepo = () => { + const findById = jest.fn(); + const updateStatusWindowAndChecks = jest + .fn() + .mockImplementation( + ( + _id: unknown, + _tid: unknown, + status: boolean, + checkSnapshot: unknown, + windowSize: number, + maxRecentChecks: number, + statusPatch?: Partial + ) => { + // Grab the monitor that findById was configured to resolve with + const monitor = findById.mock.results.at(-1)?.value; + if (monitor && typeof monitor.then === "function") { + return monitor.then((m: any) => { + m.statusWindow = m.statusWindow || []; + m.statusWindow.push(status); + while (m.statusWindow.length > windowSize) { + m.statusWindow.shift(); + } + m.recentChecks = m.recentChecks || []; + m.recentChecks.push(checkSnapshot); + while (m.recentChecks.length > maxRecentChecks) { + m.recentChecks.shift(); + } + if (statusPatch) { + Object.assign(m, statusPatch); + } + return { ...m }; + }); + } + return Promise.reject(new Error("findById not mocked")); + } + ); + return { + findById, updateById: jest.fn(), - }) as unknown as jest.Mocked; + updateStatusWindowAndChecks, + } as unknown as jest.Mocked; +}; const createMonitorStatsRepo = () => ({ @@ -233,11 +271,18 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck()); + await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); - // Window should have shifted: [true, true, true, true, false] - expect(monitor.statusWindow).toHaveLength(5); - expect(monitor.statusWindow[4]).toBe(false); + // Atomic push should have been called with the correct status and window size + expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith( + "mon-1", + "team-1", + false, + expect.any(Object), + 5, + 25, + expect.objectContaining({ status: expect.any(String) }) + ); }); it("pushes check snapshot to recentChecks and trims to 25", async () => { @@ -542,7 +587,8 @@ describe("StatusService", () => { expect(result.thresholdBreaches?.cpu).toBe(true); expect(result.thresholdBreaches?.memory).toBe(false); - expect(monitor.cpuAlertCounter).toBe(0); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(0); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("breached"); }); @@ -557,7 +603,8 @@ describe("StatusService", () => { const result = await service.updateMonitorStatus(response, makeCheck()); expect(result.thresholdBreaches?.memory).toBe(true); - expect(monitor.memoryAlertCounter).toBe(0); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.memoryAlertCounter).toBe(0); expect(result.monitor.status).toBe("breached"); }); @@ -573,7 +620,8 @@ describe("StatusService", () => { const result = await service.updateMonitorStatus(response, makeCheck()); expect(result.thresholdBreaches?.disk).toBe(true); - expect(monitor.diskAlertCounter).toBe(0); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.diskAlertCounter).toBe(0); expect(result.monitor.status).toBe("breached"); }); @@ -589,7 +637,8 @@ describe("StatusService", () => { const result = await service.updateMonitorStatus(response, makeCheck()); expect(result.thresholdBreaches?.temp).toBe(true); - expect(monitor.tempAlertCounter).toBe(0); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.tempAlertCounter).toBe(0); expect(result.monitor.status).toBe("breached"); }); @@ -609,10 +658,11 @@ describe("StatusService", () => { } as any); await service.updateMonitorStatus(response, makeCheck()); - expect(monitor.cpuAlertCounter).toBe(5); - expect(monitor.memoryAlertCounter).toBe(5); - expect(monitor.diskAlertCounter).toBe(5); - expect(monitor.tempAlertCounter).toBe(5); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(5); + expect(patch.memoryAlertCounter).toBe(5); + expect(patch.diskAlertCounter).toBe(5); + expect(patch.tempAlertCounter).toBe(5); }); it("stays breached without statusChanged when already breached and counter still at 0", async () => { @@ -841,7 +891,8 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); const result = await service.updateMonitorStatus(response, makeCheck()); - expect(monitor.cpuAlertCounter).toBe(2); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(2); expect(result.thresholdBreaches?.cpu).toBe(true); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("up"); From d40b766c83e947915a2b515cda100aab71b1ef55 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 16 Apr 2026 11:12:06 -0700 Subject: [PATCH 5/5] timescaleDB implementation --- .../monitors/TimescaleMonitorsRepository.ts | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts index a94d38504..7cd0aa4f0 100644 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -1,5 +1,5 @@ import type { Pool } from "pg"; -import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent } from "@/types/monitor.js"; +import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent, CheckSnapshot } from "@/types/monitor.js"; import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { AppError } from "@/utils/AppError.js"; @@ -641,6 +641,55 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { return entity; }; + updateStatusWindowAndChecks = async ( + monitorId: string, + teamId: string, + status: boolean, + _checkSnapshot: CheckSnapshot, + windowSize: number, + _maxRecentChecks: number, + statusPatch?: Partial + ): Promise => { + // In TimescaleDB, recentChecks live in the checks table (inserted by the buffer service), + // so we only need to atomically update status_window and any statusPatch fields. + const sets: string[] = [ + `status_window = (array_append(COALESCE(status_window, ARRAY[]::boolean[]), $1))[array_length(array_append(COALESCE(status_window, ARRAY[]::boolean[]), $1), 1) - $2 + 1:]`, + `updated_at = NOW()`, + ]; + const values: unknown[] = [status, windowSize]; + let paramIndex = 3; + + if (statusPatch) { + const patchFieldMap: [keyof Monitor, string][] = [ + ["status", "status"], + ["cpuAlertCounter", "cpu_alert_counter"], + ["memoryAlertCounter", "memory_alert_counter"], + ["diskAlertCounter", "disk_alert_counter"], + ["tempAlertCounter", "temp_alert_counter"], + ]; + for (const [key, column] of patchFieldMap) { + if (statusPatch[key] !== undefined) { + sets.push(`${column} = $${paramIndex++}`); + values.push(statusPatch[key]); + } + } + } + + values.push(monitorId, teamId); + const result = await this.pool.query( + `UPDATE monitors SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} + RETURNING ${MONITOR_COLUMNS}`, + values + ); + const row = result.rows[0]; + if (!row) { + throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 }); + } + const entity = this.toEntity(row); + entity.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); + return entity; + }; + togglePauseById = async (monitorId: string, teamId: string): Promise => { const result = await this.pool.query( `UPDATE monitors SET