Phase 1 wave 1: domain + nodered + stats infra (additive)
Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
src/domain/LatestWinsGate.js
Normal file
74
src/domain/LatestWinsGate.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
// Serialises an async dispatch so that high-frequency callers cannot stack
|
||||
// up overlapping invocations. Intermediate values are dropped — only the
|
||||
// most recent fire() during an in-flight dispatch is replayed afterwards.
|
||||
// Extracted from machineGroupControl's _dispatchInFlight + _delayedCall
|
||||
// pattern so MGC, pumpingStation, valveGroupControl etc. can share it.
|
||||
|
||||
class LatestWinsGate {
|
||||
constructor(asyncDispatchFn, options = {}) {
|
||||
if (typeof asyncDispatchFn !== 'function') {
|
||||
throw new TypeError('LatestWinsGate requires an async dispatch function');
|
||||
}
|
||||
this._dispatch = asyncDispatchFn;
|
||||
this._logger = options.logger || null;
|
||||
this._inFlight = false;
|
||||
this._pending = null; // { value, ctx } | null
|
||||
this._drainResolvers = []; // resolved when idle again
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
// 0 = idle, 1 = running with no pending, 2 = running with pending.
|
||||
get size() {
|
||||
if (!this._inFlight) return 0;
|
||||
return this._pending ? 2 : 1;
|
||||
}
|
||||
|
||||
// Never blocks the caller. If a dispatch is in flight, the latest
|
||||
// value is parked; older parked values are silently overwritten.
|
||||
fire(value, ctx) {
|
||||
if (this._inFlight) {
|
||||
this._pending = { value, ctx };
|
||||
return;
|
||||
}
|
||||
this._run(value, ctx);
|
||||
}
|
||||
|
||||
drain() {
|
||||
if (!this._inFlight && !this._pending) return Promise.resolve();
|
||||
return new Promise((resolve) => { this._drainResolvers.push(resolve); });
|
||||
}
|
||||
|
||||
_run(value, ctx) {
|
||||
this._inFlight = true;
|
||||
// Kick the dispatch on a microtask so fire() always returns
|
||||
// synchronously, even if _dispatch resolves immediately.
|
||||
Promise.resolve()
|
||||
.then(() => this._dispatch(value, ctx))
|
||||
.catch((err) => {
|
||||
this.lastError = err;
|
||||
if (this._logger && typeof this._logger.error === 'function') {
|
||||
this._logger.error(err);
|
||||
}
|
||||
// Swallow: an error must not deadlock the gate.
|
||||
})
|
||||
.then(() => this._afterDispatch());
|
||||
}
|
||||
|
||||
_afterDispatch() {
|
||||
this._inFlight = false;
|
||||
if (this._pending) {
|
||||
const { value, ctx } = this._pending;
|
||||
this._pending = null;
|
||||
this._run(value, ctx);
|
||||
return;
|
||||
}
|
||||
// Idle — release any drain() waiters.
|
||||
const waiters = this._drainResolvers;
|
||||
this._drainResolvers = [];
|
||||
for (const r of waiters) r();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LatestWinsGate;
|
||||
Reference in New Issue
Block a user