diff --git a/server/database.js b/server/database.js index 582f19c29..e110af902 100644 --- a/server/database.js +++ b/server/database.js @@ -7,7 +7,6 @@ const path = require("path"); const { EmbeddedMariaDB } = require("./embedded-mariadb"); const mysql = require("mysql2/promise"); const { Settings } = require("./settings"); -const { UptimeCalculator } = require("./uptime-calculator"); const dayjs = require("dayjs"); const { SimpleMigrationServer } = require("./utils/simple-migration-server"); const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler"); @@ -217,6 +216,7 @@ class Database { dbConfig = { type: "sqlite", }; + Database.dbConfig = dbConfig; // Fix: Also set Database.dbConfig in catch block } let config = {}; @@ -823,7 +823,8 @@ class Database { ]); for (let date of dates) { - // New Uptime Calculator + // New Uptime Calculator - import locally to avoid circular dependency + const { UptimeCalculator } = require("./uptime-calculator"); let calculator = new UptimeCalculator(); calculator.monitorID = monitor.monitor_id; calculator.setMigrationMode(true); diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js index 71d1d458c..12dd2b40b 100644 --- a/server/uptime-calculator.js +++ b/server/uptime-calculator.js @@ -306,7 +306,14 @@ class UptimeCalculator { dailyStatBean.extras = JSON.stringify(extras); } } - await R.store(dailyStatBean); + try { + await this.upsertStat("stat_daily", this.monitorID, dailyKey, + dailyData.up, dailyData.down, dailyData.avgPing, + dailyData.minPing, dailyData.maxPing); + } catch (error) { + log.warn("uptime-calc", `Upsert failed for daily stat, falling back to R.store(): ${error.message}`); + await R.store(dailyStatBean); + } let currentDate = this.getCurrentDate(); @@ -326,7 +333,14 @@ class UptimeCalculator { hourlyStatBean.extras = JSON.stringify(extras); } } - await R.store(hourlyStatBean); + try { + await this.upsertStat("stat_hourly", this.monitorID, hourlyKey, + hourlyData.up, hourlyData.down, hourlyData.avgPing, + hourlyData.minPing, hourlyData.maxPing); + } catch (error) { + log.warn("uptime-calc", `Upsert failed for hourly stat, falling back to R.store(): ${error.message}`); + await R.store(hourlyStatBean); + } } // For migration mode, we don't need to store old hourly and minutely data, but we need 24-hour's minutely data @@ -345,7 +359,14 @@ class UptimeCalculator { minutelyStatBean.extras = JSON.stringify(extras); } } - await R.store(minutelyStatBean); + try { + await this.upsertStat("stat_minutely", this.monitorID, divisionKey, + minutelyData.up, minutelyData.down, minutelyData.avgPing, + minutelyData.minPing, minutelyData.maxPing); + } catch (error) { + log.warn("uptime-calc", `Upsert failed for minutely stat, falling back to R.store(): ${error.message}`); + await R.store(minutelyStatBean); + } } // No need to remove old data in migration mode @@ -386,6 +407,11 @@ class UptimeCalculator { bean = R.dispense("stat_daily"); bean.monitor_id = this.monitorID; bean.timestamp = timestamp; + bean.up = 0; + bean.down = 0; + bean.ping = 0; + bean.pingMin = 0; + bean.pingMax = 0; } this.lastDailyStatBean = bean; @@ -411,6 +437,11 @@ class UptimeCalculator { bean = R.dispense("stat_hourly"); bean.monitor_id = this.monitorID; bean.timestamp = timestamp; + bean.up = 0; + bean.down = 0; + bean.ping = 0; + bean.pingMin = 0; + bean.pingMax = 0; } this.lastHourlyStatBean = bean; @@ -436,6 +467,11 @@ class UptimeCalculator { bean = R.dispense("stat_minutely"); bean.monitor_id = this.monitorID; bean.timestamp = timestamp; + bean.up = 0; + bean.down = 0; + bean.ping = 0; + bean.pingMin = 0; + bean.pingMax = 0; } this.lastMinutelyStatBean = bean; @@ -516,6 +552,65 @@ class UptimeCalculator { return dailyKey; } + /** + * Upsert stat data using database-specific logic to handle concurrent insertions + * @param {string} table The stat table name (stat_daily, stat_hourly, stat_minutely) + * @param {number} monitorId The monitor ID + * @param {number} timestamp The timestamp key + * @param {number} up Up count + * @param {number} down Down count + * @param {number} ping Average ping + * @param {number} pingMin Minimum ping + * @param {number} pingMax Maximum ping + * @returns {Promise} + */ + async upsertStat(table, monitorId, timestamp, up, down, ping, pingMin, pingMax) { + // Import Database locally to avoid circular dependency + const Database = require("./database"); + + // Check if database is initialized - dbConfig.type must exist and not be empty + if (!Database.dbConfig || !Database.dbConfig.type) { + log.warn("uptime-calc", `Database not initialized yet for ${table}, falling back to R.store()`); + throw new Error("Database not initialized"); + } + + const dbType = Database.dbConfig.type; + + try { + if (dbType === "sqlite") { + await R.exec(` + INSERT INTO ${table} (monitor_id, timestamp, up, down, ping, ping_min, ping_max) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(monitor_id, timestamp) DO UPDATE SET + up = ?, + down = ?, + ping = ?, + ping_min = ?, + ping_max = ? + `, [ + monitorId, timestamp, up, down, ping, pingMin, pingMax, + up, down, ping, pingMin, pingMax + ]); + } else if (dbType.endsWith("mariadb")) { + await R.exec(` + INSERT INTO ${table} (monitor_id, timestamp, up, down, ping, ping_min, ping_max) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + up = VALUES(up), + down = VALUES(down), + ping = VALUES(ping), + ping_min = VALUES(ping_min), + ping_max = VALUES(ping_max) + `, [ monitorId, timestamp, up, down, ping, pingMin, pingMax ]); + } else { + throw new Error(`Unsupported database type: ${dbType}`); + } + } catch (error) { + log.debug("uptime-calc", `Failed to upsert ${table} for monitor ${monitorId}: ${error.message}`); + throw error; + } + } + /** * Convert timestamp to key * @param {dayjs.Dayjs} datetime Datetime