diff --git a/Dockerfile b/Dockerfile index 1faeb0a..d9d7e22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ FROM node:20-alpine AS production # Set environment ENV NODE_ENV=production ENV PORT=3000 +ENV NODE_OPTIONS="--max-old-space-size=2048 --expose-gc" WORKDIR /app @@ -73,5 +74,5 @@ EXPOSE 2237/udp HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 -# Start server with explicit heap limit (fail fast rather than slow OOM at 4GB) -CMD ["node", "--max-old-space-size=1024", "server.js"] +# Start server with explicit heap limit and GC access for periodic compaction +CMD ["node", "--max-old-space-size=2048", "--expose-gc", "server.js"] diff --git a/server.js b/server.js index 6fa192e..65241be 100644 --- a/server.js +++ b/server.js @@ -895,7 +895,11 @@ function loadVisitorStats() { totalRequestsToday: data.today === new Date().toISOString().slice(0, 10) ? (data.totalRequestsToday || 0) : 0, allTimeVisitors: data.allTimeVisitors || 0, allTimeRequests: data.allTimeRequests || 0, - allTimeUniqueIPs: data.allTimeUniqueIPs || [], + // Reconstruct from geoIPCache keys (covers ~99% of IPs) + any legacy array + allTimeUniqueIPs: [...new Set([ + ...(data.allTimeUniqueIPs || []), + ...Object.keys(data.geoIPCache || {}) + ])], serverFirstStarted: data.serverFirstStarted || defaults.serverFirstStarted, lastDeployment: new Date().toISOString(), deploymentCount: (data.deploymentCount || 0) + 1, @@ -925,12 +929,16 @@ function saveVisitorStats() { fs.mkdirSync(dir, { recursive: true }); } + // Don't persist allTimeUniqueIPs array — it grows forever and can be + // reconstructed from geoIPCache keys on restart. Save memory. const data = { ...visitorStats, + allTimeUniqueIPs: undefined, // Exclude from JSON — reconstructed on load lastSaved: new Date().toISOString() }; - fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2)); + // Use compact JSON (no pretty-print) to avoid multi-MB temporary strings + fs.writeFileSync(STATS_FILE, JSON.stringify(data)); visitorStats.lastSaved = data.lastSaved; // Update in-memory too saveErrorCount = 0; // Reset on success // Only log occasionally to avoid spam @@ -955,6 +963,10 @@ const visitorStats = loadVisitorStats(); // Convert today's IPs to a Set for fast lookup const todayIPSet = new Set(visitorStats.uniqueIPsToday); const allTimeIPSet = new Set(visitorStats.allTimeUniqueIPs); +const MAX_TRACKED_IPS = 100000; // Stop tracking individual IPs after this (just count) + +// Free the array — Set is the authoritative source now, array is no longer persisted +visitorStats.allTimeUniqueIPs = []; // ============================================ // GEO-IP COUNTRY RESOLUTION @@ -998,8 +1010,11 @@ function queueGeoIPLookup(ip) { */ function recordCountry(ip, countryCode) { if (!countryCode || countryCode === 'Unknown') return; - geoIPCache.set(ip, countryCode); - visitorStats.geoIPCache[ip] = countryCode; + // Only cache individual IP→country mappings up to the cap + if (geoIPCache.size < MAX_TRACKED_IPS || geoIPCache.has(ip)) { + geoIPCache.set(ip, countryCode); + visitorStats.geoIPCache[ip] = countryCode; + } // All-time stats visitorStats.countryStats[countryCode] = (visitorStats.countryStats[countryCode] || 0) + 1; @@ -1332,8 +1347,10 @@ app.use((req, res, next) => { // Track all-time unique visitors const isNewAllTime = !allTimeIPSet.has(ip); if (isNewAllTime) { - allTimeIPSet.add(ip); - visitorStats.allTimeUniqueIPs.push(ip); + // Only track individual IPs up to the cap (prevents unbounded memory growth) + if (allTimeIPSet.size < MAX_TRACKED_IPS) { + allTimeIPSet.add(ip); + } visitorStats.allTimeVisitors++; queueGeoIPLookup(ip); logInfo(`[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`); @@ -1370,9 +1387,23 @@ setInterval(() => { spotBufferEntries: pskMqtt.spotBuffer.size, spotBufferTotal: [...pskMqtt.spotBuffer.values()].reduce((n, b) => n + b.length, 0), }; - console.log(`[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size}`); + console.log(`[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size} RBN=${rbnSpotsByDX?.size || 0}`); }, 15 * 60 * 1000); +// Periodic GC compaction — helps V8 release fragmented old-space memory +// Without this, long-running processes slowly accumulate unreclaimable heap +setInterval(() => { + if (typeof global.gc === 'function') { + const before = process.memoryUsage().heapUsed; + global.gc(); + const after = process.memoryUsage().heapUsed; + const freed = ((before - after) / 1024 / 1024).toFixed(1); + if (freed > 5) { + console.log(`[GC] Compaction freed ${freed}MB (${(before / 1024 / 1024).toFixed(0)}MB → ${(after / 1024 / 1024).toFixed(0)}MB)`); + } + } +}, 30 * 60 * 1000); // Every 30 minutes + // ============================================ // AUTO UPDATE (GIT) // ============================================