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:
Jon Vanvik 2026-06-23 14:21:04 +02:00
parent 96b0264179
commit 6ee1d37a79
7 changed files with 309 additions and 35 deletions

79
web/cache.js Normal file
View 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 };