diff --git a/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts new file mode 100644 index 000000000..1371ab798 --- /dev/null +++ b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts @@ -0,0 +1,81 @@ +import { MonitorStatsModel } from "../models/MonitorStats.js"; +import { logger } 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 { + const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStats"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting cleanup of duplicate MonitorStats" }); + + // Find all duplicate monitorIds + const duplicates = await MonitorStatsModel.aggregate([ + { + $group: { + _id: "$monitorId", + ids: { $push: "$_id" }, + updatedAts: { $push: "$updatedAt" }, + count: { $sum: 1 }, + }, + }, + { $match: { count: { $gt: 1 } } }, + ]); + + if (duplicates.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" }); + return; + } + + logger.info({ + service: SERVICE_NAME, + message: `Found ${duplicates.length} monitors with duplicate stats`, + }); + + let totalDeleted = 0; + + // For each set of duplicates, keep the newest and delete the rest + for (const duplicate of duplicates) { + const monitorId = duplicate._id; + const { ids, updatedAts } = duplicate; + + type DocPair = { id: any; updatedAt: Date }; + + // Create array of {id, updatedAt} pairs and sort by updatedAt descending + const docs: DocPair[] = ids.map((id: any, index: number) => ({ + id, + updatedAt: updatedAts[index], + })); + + docs.sort((a: DocPair, b: DocPair) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + // Keep the first (newest), delete the rest + const toDelete = docs.slice(1).map((doc: DocPair) => doc.id); + + if (toDelete.length > 0) { + const result = await MonitorStatsModel.deleteMany({ + _id: { $in: toDelete }, + }); + + totalDeleted += result.deletedCount ?? 0; + + logger.debug({ + service: SERVICE_NAME, + message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`, + }); + } + } + + logger.info({ + service: SERVICE_NAME, + message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats cleanup: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index c8e9e7c75..8ea374514 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -1,5 +1,6 @@ import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js"; import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js"; +import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js"; import MigrationModel from "../models/Migration.js"; type MigrationEntry = { @@ -10,6 +11,7 @@ type MigrationEntry = { const migrations: MigrationEntry[] = [ { name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold }, { name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries }, + { name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats }, ]; const runMigrations = async (logger?: { info: Function; error: Function }) => { diff --git a/server/src/db/models/MonitorStats.ts b/server/src/db/models/MonitorStats.ts index fda5ccbd5..7f5c94798 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/db/models/MonitorStats.ts @@ -18,6 +18,7 @@ const MonitorStatsSchema = new Schema( ref: "Monitor", immutable: true, index: true, + unique: true, required: true, }, avgResponseTime: { diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts index 1ad05596e..4abdeb33f 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts @@ -5,6 +5,7 @@ export interface IMonitorStatsRepository { // single fetch findByMonitorId(monitorId: string): Promise; // update + updateByMonitorId(monitorId: string, data: Omit): Promise; // delete deleteByMonitorId(monitorId: string): Promise; deleteByMonitorIds(monitorIds: string[]): Promise; diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 39c837ac8..774405587 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -46,6 +46,14 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { return this.toEntity(monitorStats); }; + updateByMonitorId = async (monitorId: string, data: Omit): Promise => { + const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true }); + if (!updated) { + throw new AppError({ message: "Monitor stats not found", status: 404 }); + } + return this.toEntity(updated); + }; + deleteByMonitorId = async (monitorId: string) => { const deleted = await MonitorStatsModel.findOneAndDelete({ monitorId: new mongoose.Types.ObjectId(monitorId) }); if (!deleted) { diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index cf42f8840..37657cb79 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -52,8 +52,8 @@ export class StatusService implements IStatusService { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; - let stats: Omit | null = null; - stats = await this.monitorStatsRepository + let existingStats: MonitorStats | null = null; + existingStats = await this.monitorStatsRepository .findByMonitorId(monitorId) .then((result) => result) .catch(() => { @@ -64,9 +64,12 @@ export class StatusService implements IStatusService { }); return null; }); - if (!stats) { + + let stats: Omit; + + if (!existingStats) { + // Initialize new stats stats = { - monitorId, avgResponseTime: 0, maxResponseTime: 0, totalChecks: 0, @@ -76,6 +79,19 @@ export class StatusService implements IStatusService { lastResponseTime: 0, lastCheckTimestamp: 0, }; + } else { + // Use existing stats (omit id, monitorId, createdAt, updatedAt) + stats = { + avgResponseTime: existingStats.avgResponseTime, + maxResponseTime: existingStats.maxResponseTime, + totalChecks: existingStats.totalChecks, + totalUpChecks: existingStats.totalUpChecks, + totalDownChecks: existingStats.totalDownChecks, + uptimePercentage: existingStats.uptimePercentage, + lastResponseTime: existingStats.lastResponseTime, + lastCheckTimestamp: existingStats.lastCheckTimestamp, + timeOfLastFailure: existingStats.timeOfLastFailure, + }; } // Update stats @@ -123,7 +139,14 @@ export class StatusService implements IStatusService { // latest check stats.lastCheckTimestamp = new Date().getTime(); - await this.monitorStatsRepository.create(stats); + + // Create or update + if (!existingStats) { + await this.monitorStatsRepository.create({ monitorId, ...stats }); + } else { + await this.monitorStatsRepository.updateByMonitorId(monitorId, stats); + } + return true; } catch (error: any) { this.logger.error({