web: shared MCMS cache + systemd/clone-to-run deploy + NPM docs
Caching (reduce MCMS API load with multiple operators): - web/cache.js: in-memory TTL cache with single-flight, keyed by MCMS host. Concurrent identical bulk reads collapse into ONE upstream fetch; everyone on the same MCMS shares the snapshot. - Wired into the bulk reads only (ONU/OLT config+state lists, controllers, firmware). Per-ONU read-modify-write and the FEC pre-flight stay live. - Writes (delete / per-ONU + bulk upgrade / flood change / firmware upload) invalidate the affected datasets for that host, so changes show on the next load instead of waiting out the TTL. - PFW_CACHE_TTL_SECONDS (default 60, 0 disables). Authenticated _cacheStats / _cacheClear endpoints for ops. Deploy (Debian 13 LXC, clone-to-run) in web/deploy/: - pon-fleet-web.service (runs as unprivileged ponfw, hardened, repo read-only, no disk writes), pon-fleet-web.env.example, update.sh (git pull + npm install --omit=dev + restart; uses install not ci since package-lock.json is gitignored). - README: full LXC setup, Nginx Proxy Manager proxy-host + access-list notes. Streams already send X-Accel-Buffering: no so NPM/nginx don't buffer the NDJSON progress. Verified: node --check; cache unit-tested (single-flight, TTL hit/expiry, fresh, invalidate, key isolation, disable — 9/9); server boots and reports the cache TTL; _cacheStats gated to logged-in sessions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
96b0264179
commit
6ee1d37a79
7 changed files with 309 additions and 35 deletions
79
web/cache.js
Normal file
79
web/cache.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// 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;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue