class MetricAggregator { constructor(options = {}) { this.logger = options.logger || console; this.onFlush = typeof options.onFlush === 'function' ? options.onFlush : async () => true; this.mode = String(options.mode || 'batch').trim().toLowerCase(); this.flushIntervalMs = Number(options.flushIntervalMs) > 0 ? Number(options.flushIntervalMs) : 60000; this.alignToMinute = options.alignToMinute !== false; this.includeDeviceIdField = options.includeDeviceIdField !== false; this.deviceIdField = options.deviceIdField || 'n'; this.publishOnShutdown = options.publishOnShutdown !== false; this.cache = new Map(); this.timer = null; this.started = false; this.flushing = false; this.queuedFlushOptions = null; } start() { if (this.started) { return; } this.started = true; if (this.mode === 'batch') { this.scheduleNextFlush(); } } async stop() { this.started = false; if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.mode === 'batch' && this.publishOnShutdown) { await this.flush({ reason: 'shutdown' }); } } ingest(device, metric = {}) { if (!device || !device.deviceId || !metric || typeof metric !== 'object') { return Promise.resolve(false); } const entry = this.getOrCreateEntry(device); entry.device = device; const now = Date.now(); // 首次进入脏状态时记录时间,便于控制“缓存满一轮再发送”。 if (!entry.dirty) { entry.dirtySinceAt = now; } entry.dirty = true; entry.lastUpdateAt = now; if (this.includeDeviceIdField) { entry.payload[this.deviceIdField] = device.deviceId; } Object.assign(entry.payload, metric); if (this.mode === 'immediate') { return this.flush({ reason: 'immediate', deviceId: device.deviceId }); } return Promise.resolve(true); } getOrCreateEntry(device) { if (!this.cache.has(device.deviceId)) { this.cache.set(device.deviceId, { device, payload: {}, dirty: false, dirtySinceAt: 0, firstSeenAt: Date.now(), lastUpdateAt: 0, lastFlushAt: 0, }); } return this.cache.get(device.deviceId); } computeDelayMs() { if (!this.alignToMinute) { return this.flushIntervalMs; } const remainder = Date.now() % this.flushIntervalMs; return remainder === 0 ? this.flushIntervalMs : this.flushIntervalMs - remainder; } scheduleNextFlush() { if (!this.started || this.mode !== 'batch') { return; } if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => { this.flush({ reason: 'timer' }).catch((error) => { this.logger.error(`[APP] 聚合器刷新失败: ${error.message}`); }); }, this.computeDelayMs()); if (typeof this.timer.unref === 'function') { this.timer.unref(); } } mergeQueuedFlushOptions(options = {}) { const nextOptions = { reason: options.reason || 'manual', deviceId: options.deviceId || '', }; if (!this.queuedFlushOptions) { this.queuedFlushOptions = nextOptions; return; } const currentOptions = this.queuedFlushOptions; const currentIsTimer = currentOptions.reason === 'timer' || currentOptions.reason === 'queued'; const nextIsTimer = nextOptions.reason === 'timer' || nextOptions.reason === 'queued'; if (currentIsTimer && !nextIsTimer) { currentOptions.reason = nextOptions.reason; } if (!currentOptions.deviceId || !nextOptions.deviceId || currentOptions.deviceId !== nextOptions.deviceId) { currentOptions.deviceId = ''; } } async flush(options = {}) { const reason = options.reason || 'manual'; const deviceId = options.deviceId || ''; if (this.flushing) { this.mergeQueuedFlushOptions({ reason, deviceId }); return false; } this.flushing = true; if (this.timer) { clearTimeout(this.timer); this.timer = null; } try { const entries = Array.from(this.cache.values()).filter((entry) => { if (!entry.dirty) { return false; } if (deviceId && entry.device.deviceId !== deviceId) { return false; } // 定时触发时要求数据至少缓存满一个周期,避免刚收到就被单独发出。 if ((reason === 'timer' || reason === 'queued') && entry.dirtySinceAt > 0) { const dirtyAgeMs = Date.now() - entry.dirtySinceAt; if (dirtyAgeMs < this.flushIntervalMs) { return false; } } return true; }); for (const entry of entries) { const flushUpdateAt = entry.lastUpdateAt; const payload = { ...entry.payload, }; if (this.includeDeviceIdField) { payload[this.deviceIdField] = entry.device.deviceId; } try { const result = await this.onFlush(entry.device, payload, { reason, firstSeenAt: entry.firstSeenAt, lastUpdateAt: entry.lastUpdateAt, lastFlushAt: entry.lastFlushAt, }); if (result === false) { continue; } if (entry.lastUpdateAt === flushUpdateAt) { entry.dirty = false; entry.dirtySinceAt = 0; } entry.lastFlushAt = Date.now(); } catch (error) { this.logger.error(`[APP] 聚合器设备刷新失败 deviceId=${entry.device.deviceId}: ${error.message}`); } } return true; } finally { this.flushing = false; if (this.started && this.mode === 'batch') { this.scheduleNextFlush(); } if (this.queuedFlushOptions) { const queuedOptions = this.queuedFlushOptions; this.queuedFlushOptions = null; await this.flush(queuedOptions); } } } getSnapshot(deviceId) { if (!deviceId) { return Array.from(this.cache.values()).map((entry) => ({ deviceId: entry.device.deviceId, payload: { ...entry.payload }, dirty: entry.dirty, })); } const entry = this.cache.get(deviceId); if (!entry) { return null; } return { deviceId: entry.device.deviceId, payload: { ...entry.payload }, dirty: entry.dirty, }; } } module.exports = { MetricAggregator, };