The web server's dashboardStats now reports how stale its shared cache
snapshot is — data.cache = { enabled, ttlMs, ageMs }, using the age of the
oldest dataset feeding the view (OLT-STATE, plus ONU-STATE under detailed
scan). cache.js gains ageOf(key).
The dashboard renders a live-ticking badge from it (renderCacheBadge):
- within TTL -> "◷ cached Ns ago" (info)
- past TTL -> same, warn-coloured (next load will refetch)
- no cache -> "● live" (Electron app, or PFW_CACHE_TTL_SECONDS=0)
The Dashboard Refresh button now sends fresh:true to bypass the cache and
reset the age; the detailed-stats toggle and auto-loads use the cache.
Shared renderer change, so the Electron app shows "live" (it sends no cache
field). Verified offscreen over HTTP: cached / stale / live states render
with the right text + colour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
2.9 KiB
JavaScript
85 lines
2.9 KiB
JavaScript
// In-memory TTL cache with single-flight, shared across all browser sessions,
|
|
// to collapse repeated MCMS bulk fetches into one upstream request.
|
|
//
|
|
// Keyed by "<mcms-base-url>|<dataset>": operators pointed at the SAME MCMS
|
|
// share a snapshot (the inventory is identical regardless of which
|
|
// authenticated user fetched it), while different MCMS hosts never cross.
|
|
//
|
|
// Only slow-changing bulk *reads* are cached (ONU/OLT config + state lists,
|
|
// firmware inventory). Per-ONU reads used for read-modify-write (getOnuConfig
|
|
// before a PUT) and the FEC pre-flight are deliberately NOT cached — they must
|
|
// be live. Writes invalidate the affected datasets so the next read refetches.
|
|
|
|
class TtlCache {
|
|
constructor(ttlMs) {
|
|
this.ttl = ttlMs;
|
|
this.entries = new Map(); // key -> { value, at }
|
|
this.inflight = new Map(); // key -> Promise (single-flight)
|
|
this.hits = 0;
|
|
this.misses = 0;
|
|
this.coalesced = 0;
|
|
}
|
|
|
|
enabled() { return this.ttl > 0; }
|
|
|
|
/**
|
|
* Return the cached value for `key` if fresh; otherwise call `producer()`
|
|
* once (coalescing concurrent callers) and cache the result.
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.fresh] bypass a still-fresh cached entry
|
|
*/
|
|
async get(key, producer, { fresh = false } = {}) {
|
|
if (!this.enabled()) return producer();
|
|
|
|
const now = Date.now();
|
|
const e = this.entries.get(key);
|
|
if (!fresh && e && (now - e.at) < this.ttl) { this.hits++; return e.value; }
|
|
|
|
// Single-flight: if a fetch for this key is already running, join it
|
|
// (even a `fresh` request — it's fetching right now anyway).
|
|
const existing = this.inflight.get(key);
|
|
if (existing) { this.coalesced++; return existing; }
|
|
|
|
this.misses++;
|
|
const p = Promise.resolve()
|
|
.then(producer)
|
|
.then(
|
|
(value) => { this.entries.set(key, { value, at: Date.now() }); this.inflight.delete(key); return value; },
|
|
(err) => { this.inflight.delete(key); throw err; },
|
|
);
|
|
this.inflight.set(key, p);
|
|
return p;
|
|
}
|
|
|
|
/** Age (ms) of the cached entry for `key`, or null if not cached. */
|
|
ageOf(key) {
|
|
const e = this.entries.get(key);
|
|
return e ? Date.now() - e.at : null;
|
|
}
|
|
|
|
/** Drop every entry whose key starts with `prefix` (e.g. one MCMS host's
|
|
* dataset). Returns how many were removed. */
|
|
invalidate(prefix) {
|
|
let n = 0;
|
|
for (const k of [...this.entries.keys()]) {
|
|
if (!prefix || k.startsWith(prefix)) { this.entries.delete(k); n++; }
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/** Free memory: drop entries far older than the TTL (idle datasets). */
|
|
sweep(maxAgeMs) {
|
|
const cutoff = Date.now() - maxAgeMs;
|
|
for (const [k, e] of [...this.entries]) if (e.at < cutoff) this.entries.delete(k);
|
|
}
|
|
|
|
stats() {
|
|
return {
|
|
ttlMs: this.ttl, enabled: this.enabled(), size: this.entries.size,
|
|
hits: this.hits, misses: this.misses, coalesced: this.coalesced,
|
|
keys: [...this.entries.keys()],
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { TtlCache };
|