diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 8aea56279..59d2a32b7 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -35,6 +35,12 @@ class MongoDB implements IDb { setDefaultsOnInsert: true, } ); + // Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before + // 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(); + // Sync indexes const models = mongoose.modelNames(); for (const modelName of models) { @@ -47,8 +53,6 @@ class MongoDB implements IDb { service: SERVICE_NAME, method: "connect", }); - - await runMigrations(); } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", diff --git a/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts new file mode 100644 index 000000000..814b19776 --- /dev/null +++ b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts @@ -0,0 +1,78 @@ +import { MonitorStatsModel } from "../models/MonitorStats.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced. + * + * 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats + * could create new duplicates after 0003 completed. This migration re-runs the dedupe so + * the unique index added in the same PR can be built without failing. + * + * 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 { + const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" }); + + const duplicates = await MonitorStatsModel.aggregate([ + { + $group: { + _id: "$monitorId", + docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$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; + + type DocInfo = { id: string; totalChecks: number; updatedAt: Date }; + + for (const duplicate of duplicates) { + const monitorId = duplicate._id; + const docs: DocInfo[] = duplicate.docs; + + // Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest. + docs.sort((a, b) => { + const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0); + if (checksDiff !== 0) return checksDiff; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + + const toDelete = docs.slice(1).map((doc) => 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 dedupe: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index f1a672af9..5b74a96fc 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js"; import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; +import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [ { name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats }, { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, + { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, ]; const runMigrations = async (logger?: ILogger) => {