// Binary ring buffer for system metrics, on-disk. // // Two tiers feed the Admin → System trend charts: // - 1m: 1440 points → 24 h at 1-minute resolution // - 5m: 2016 points → 7 d at 5-minute resolution // // Why binary instead of JSON: JSON parse of a 2016-point array is enough work // (~ms in the libreportal container) that the history endpoint felt sluggish // at 7-day ranges. A fixed 32-byte-per-point binary file is ~10× smaller, has // O(range) cost with no parse, and the file is mmap-friendly if we ever need // even larger windows. // // On-disk layout (little-endian): // HEADER (32 B) // 0 4 magic "LPMR" // 4 1 version 0x01 // 5 1 point_size bytes per point (32) // 6 1 field_count metric fields per point, not counting timestamp (7) // 7 1 flags reserved // 8 4 capacity max points // 12 4 head next write index (0..capacity-1) // 16 4 count valid points (<= capacity) // 20 4 bucket_sec seconds per bucket (60 | 300) // 24 4 last_t last bucket timestamp, unix seconds // 28 4 reserved // // POINT (32 B) // 0 4 uint32 t bucket start, unix seconds // 4 4 float32 cpu % // 8 4 float32 mem % // 12 4 float32 swap % // 16 4 float32 disk % (root mount) // 20 4 float32 load1 // 24 4 float32 net_rx bytes/sec average over bucket // 28 4 float32 net_tx bytes/sec // // File size: 32 + capacity * 32. For our tiers, 46 KB and 64 KB respectively. // All writes are append-only (no mid-ring rewrites) and atomic at the byte- // range level: we open with O_RDWR, write a single 32-byte point at the slot // offset, then patch the header. A torn write is recoverable (count won't // have advanced; the slot is just garbage that'll be overwritten next tick). const fs = require('fs'); const path = require('path'); const MAGIC = Buffer.from('LPMR', 'ascii'); const VERSION = 0x01; const POINT_SIZE = 32; const HEADER_SIZE = 32; const FIELD_COUNT = 7; const FIELDS = ['cpu', 'mem', 'swap', 'disk', 'load1', 'net_rx', 'net_tx']; // Build a new header buffer for `capacity` points at `bucketSec` resolution. function newHeader(capacity, bucketSec) { const h = Buffer.alloc(HEADER_SIZE); MAGIC.copy(h, 0); h.writeUInt8(VERSION, 4); h.writeUInt8(POINT_SIZE, 5); h.writeUInt8(FIELD_COUNT, 6); h.writeUInt8(0, 7); h.writeUInt32LE(capacity, 8); h.writeUInt32LE(0, 12); // head h.writeUInt32LE(0, 16); // count h.writeUInt32LE(bucketSec, 20); h.writeUInt32LE(0, 24); // last_t h.writeUInt32LE(0, 28); return h; } function parseHeader(buf) { if (buf.length < HEADER_SIZE) throw new Error('ring: short header'); if (buf.slice(0, 4).compare(MAGIC) !== 0) throw new Error('ring: bad magic'); if (buf.readUInt8(4) !== VERSION) throw new Error('ring: unsupported version'); if (buf.readUInt8(5) !== POINT_SIZE) throw new Error('ring: wrong point size'); return { version: buf.readUInt8(4), pointSize: buf.readUInt8(5), fieldCount: buf.readUInt8(6), capacity: buf.readUInt32LE(8), head: buf.readUInt32LE(12), count: buf.readUInt32LE(16), bucketSec: buf.readUInt32LE(20), lastT: buf.readUInt32LE(24), }; } function encodePoint(p) { const b = Buffer.alloc(POINT_SIZE); b.writeUInt32LE(Math.max(0, Math.floor(p.t || 0)), 0); b.writeFloatLE(Number(p.cpu) || 0, 4); b.writeFloatLE(Number(p.mem) || 0, 8); b.writeFloatLE(Number(p.swap) || 0, 12); b.writeFloatLE(Number(p.disk) || 0, 16); b.writeFloatLE(Number(p.load1) || 0, 20); b.writeFloatLE(Number(p.net_rx) || 0, 24); b.writeFloatLE(Number(p.net_tx) || 0, 28); return b; } function decodePoint(buf, offset = 0) { return { t: buf.readUInt32LE(offset), cpu: +buf.readFloatLE(offset + 4).toFixed(2), mem: +buf.readFloatLE(offset + 8).toFixed(2), swap: +buf.readFloatLE(offset + 12).toFixed(2), disk: +buf.readFloatLE(offset + 16).toFixed(2), load1: +buf.readFloatLE(offset + 20).toFixed(3), net_rx: Math.round(buf.readFloatLE(offset + 24)), net_tx: Math.round(buf.readFloatLE(offset + 28)), }; } class MetricsRing { // file: absolute path to the .bin // capacity: max points // bucketSec: seconds per bucket (60 | 300) constructor({ file, capacity, bucketSec }) { this.file = file; this.capacity = capacity; this.bucketSec = bucketSec; this.fd = null; this.header = null; } // Open (creating if missing) and validate. Safe to call repeatedly. async open() { if (this.fd !== null) return; const dir = path.dirname(this.file); try { await fs.promises.mkdir(dir, { recursive: true }); } catch (_) {} let needInit = false; try { const st = await fs.promises.stat(this.file); const expected = HEADER_SIZE + this.capacity * POINT_SIZE; if (st.size !== expected) needInit = true; } catch (_) { needInit = true; } if (needInit) { const full = Buffer.alloc(HEADER_SIZE + this.capacity * POINT_SIZE); newHeader(this.capacity, this.bucketSec).copy(full, 0); await fs.promises.writeFile(this.file, full); } this.fd = await fs.promises.open(this.file, 'r+'); const headBuf = Buffer.alloc(HEADER_SIZE); await this.fd.read(headBuf, 0, HEADER_SIZE, 0); this.header = parseHeader(headBuf); if (this.header.capacity !== this.capacity || this.header.bucketSec !== this.bucketSec) { // Capacity / bucket changed (config bump) — start fresh. Old data // wouldn't line up with the new grid anyway. await this.fd.close(); this.fd = null; await fs.promises.unlink(this.file); return this.open(); } } async close() { if (this.fd) { try { await this.fd.close(); } catch (_) {} this.fd = null; } } // Append a single point. Caller is responsible for rounding `t` to the // bucket boundary (e.g. Math.floor(now / bucketSec) * bucketSec). async append(point) { await this.open(); const slot = this.header.head; const offset = HEADER_SIZE + slot * POINT_SIZE; const buf = encodePoint(point); await this.fd.write(buf, 0, POINT_SIZE, offset); this.header.head = (slot + 1) % this.capacity; this.header.count = Math.min(this.capacity, this.header.count + 1); this.header.lastT = point.t >>> 0; const h = Buffer.alloc(HEADER_SIZE); // Re-encode only the mutable fields; the static prefix stays the same. MAGIC.copy(h, 0); h.writeUInt8(VERSION, 4); h.writeUInt8(POINT_SIZE, 5); h.writeUInt8(FIELD_COUNT, 6); h.writeUInt8(0, 7); h.writeUInt32LE(this.capacity, 8); h.writeUInt32LE(this.header.head, 12); h.writeUInt32LE(this.header.count, 16); h.writeUInt32LE(this.bucketSec, 20); h.writeUInt32LE(this.header.lastT, 24); h.writeUInt32LE(0, 28); await this.fd.write(h, 0, HEADER_SIZE, 0); // fdatasync would be safer against power loss but doubles cost; the // worst case here is losing the most recent minute or two of metrics // — not worth the IO penalty on every append. } // Read the last `n` points in chronological order (oldest → newest). // Returns [] if the ring is empty. async readLast(n) { await this.open(); const { head, count, capacity } = this.header; if (count === 0) return []; const want = Math.max(1, Math.min(n | 0, count)); // The oldest of `want` lives `want` slots before head (modulo cap). const start = (head - want + capacity) % capacity; // Two-segment read so we don't span the wrap unnecessarily. const out = new Array(want); if (start + want <= capacity) { const buf = Buffer.alloc(want * POINT_SIZE); await this.fd.read(buf, 0, buf.length, HEADER_SIZE + start * POINT_SIZE); for (let i = 0; i < want; i++) out[i] = decodePoint(buf, i * POINT_SIZE); } else { const firstLen = capacity - start; const a = Buffer.alloc(firstLen * POINT_SIZE); const b = Buffer.alloc((want - firstLen) * POINT_SIZE); await this.fd.read(a, 0, a.length, HEADER_SIZE + start * POINT_SIZE); await this.fd.read(b, 0, b.length, HEADER_SIZE); for (let i = 0; i < firstLen; i++) out[i] = decodePoint(a, i * POINT_SIZE); for (let i = 0; i < want - firstLen; i++) out[firstLen + i] = decodePoint(b, i * POINT_SIZE); } return out; } // Convenience: latest bucket timestamp on disk (0 if empty). async lastT() { await this.open(); return this.header.lastT || 0; } } module.exports = { MetricsRing, FIELDS, HEADER_SIZE, POINT_SIZE };