Skip to content

Commit 6e9d0b7

Browse files
committed
refactor: extract clampSetting + evictOldestFifo helpers (deduplicates 15 sites)
Simplify-pass over the 1.9.1 perf batch flagged two repeated patterns: 1. clampSetting(value, floor, default) wraps Math.max(floor, Number(value) || default). Used 11 times across eventPublisher, deviceStateManager, cgateWebBridge, throttledQueue, staleDeviceDetector, and haBridgeDiagnostics for reading numeric settings with a hard floor and a fallback when the value is missing/NaN/zero. 2. evictOldestFifo(map) encapsulates `const k = map.keys().next().value; map.delete(k); return k;` - the unintuitive FIFO-from-Map idiom. Used 4 times across mqttManager (_pendingPublishQueue), deviceStateManager (_deviceLevels with sibling _lastSeen evicted in lockstep), and eventPublisher (_topicCache + _recentPublishes). Both helpers live in a new src/utils.js with their own unit tests (10 cases covering defaults, floors, FIFO order, edge cases). Two smaller follow-ups also applied: - The 4-line comment about the deferred web server start in cgateWebBridge.js trimmed to one line (only the WHY survives - WHAT was just narration). - The readiness-before-post-connect-work test in bridgeInitializationService.test.js switched from a brittle `eventOrder[0] === 'readiness'` check to indexOf comparisons that verify the actual ordering constraint (readiness before queue-add AND discovery-trigger). No behaviour change anywhere; 1254/1254 tests pass.
1 parent c748d70 commit 6e9d0b7

10 files changed

Lines changed: 117 additions & 29 deletions

src/cgateWebBridge.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const StaleDeviceDetector = require('./staleDeviceDetector');
1616
const { createLogger } = require('./logger');
1717
const { LineProcessor } = require('./lineProcessor');
1818
const { MQTT_RETAINED_STATE_OPTIONS } = require('./constants');
19+
const { clampSetting } = require('./utils');
1920

2021
/**
2122
* Main bridge class that connects C-Gate (Clipsal C-Bus automation system) to MQTT.
@@ -294,10 +295,7 @@ class CgateWebBridge {
294295
this.staleDeviceDetector.start();
295296
this._updateBridgeReadiness('startup-complete');
296297

297-
// The C-Bus Labels web UI is never exercised during the boot window,
298-
// so start it in the background after readiness has fired. Keeping it
299-
// off the await chain shaves it off the critical startup path; failure
300-
// logs without blocking the bridge.
298+
// Off the await chain so it never gates the critical startup path.
301299
this.webServer.start().catch((err) => {
302300
this.logger.warn(`Web server failed to start: ${err.message}`);
303301
});
@@ -431,8 +429,8 @@ class CgateWebBridge {
431429
}
432430

433431
_getAdaptiveQueueIntervalMs() {
434-
const baseInterval = Math.max(10, Number(this.settings.messageinterval) || 200);
435-
const minInterval = Math.max(5, Number(this.settings.commandMinIntervalMs) || 10);
432+
const baseInterval = clampSetting(this.settings.messageinterval, 10, 200);
433+
const minInterval = clampSetting(this.settings.commandMinIntervalMs, 5, 10);
436434
const stats = this.commandConnectionPool?.getStats?.();
437435
if (!stats || stats.healthyConnections <= 0) {
438436
return baseInterval;

src/deviceStateManager.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { EventEmitter } = require('events');
22
const { createLogger } = require('./logger');
3+
const { clampSetting, evictOldestFifo } = require('./utils');
34
const {
45
MQTT_TOPIC_SUFFIX_LEVEL,
56
CGATE_CMD_ON,
@@ -59,9 +60,8 @@ class DeviceStateManager {
5960
// Track last-seen timestamp per device address (network/app/group → ms since epoch)
6061
this._lastSeen = new Map();
6162

62-
// Bound on _deviceLevels / _lastSeen growth. Matches the floor + default
63-
// pattern used by eventPublisher's topicCacheMaxEntries / dedup cache.
64-
this._maxEntries = Math.max(100, Number(this.settings.deviceStateMaxEntries) || 5000);
63+
// Bound on _deviceLevels / _lastSeen growth.
64+
this._maxEntries = clampSetting(this.settings.deviceStateMaxEntries, 100, 5000);
6565
}
6666

6767
/**
@@ -117,10 +117,10 @@ class DeviceStateManager {
117117
if (levelValue !== null) {
118118
this.logger.debug(`Level update: ${simpleAddr} = ${levelValue}`);
119119
// FIFO-evict the oldest entry before inserting to keep both maps
120-
// bounded. Matches eventPublisher's topic-cache pattern.
120+
// bounded; _lastSeen tracks the same key space so it must evict in
121+
// lockstep.
121122
if (!this._deviceLevels.has(simpleAddr) && this._deviceLevels.size >= this._maxEntries) {
122-
const oldestKey = this._deviceLevels.keys().next().value;
123-
this._deviceLevels.delete(oldestKey);
123+
const oldestKey = evictOldestFifo(this._deviceLevels);
124124
this._lastSeen.delete(oldestKey);
125125
}
126126
// Store latest known level so callers can retrieve it synchronously

src/eventPublisher.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { createLogger } = require('./logger');
2+
const { clampSetting, evictOldestFifo } = require('./utils');
23
const {
34
MQTT_TOPIC_PREFIX_READ,
45
MQTT_TOPIC_SUFFIX_STATE,
@@ -34,9 +35,9 @@ class EventPublisher {
3435
this.labelLoader = options.labelLoader || null;
3536
this.coverRampTracker = options.coverRampTracker || null;
3637
this.onEventLog = options.onEventLog || null;
37-
this.eventPublishDedupWindowMs = Math.max(0, Number(this.settings.eventPublishDedupWindowMs) || 0);
38-
this.eventPublishDedupMaxEntries = Math.max(100, Number(this.settings.eventPublishDedupMaxEntries) || 5000);
39-
this.topicCacheMaxEntries = Math.max(100, Number(this.settings.topicCacheMaxEntries) || 5000);
38+
this.eventPublishDedupWindowMs = clampSetting(this.settings.eventPublishDedupWindowMs, 0, 0);
39+
this.eventPublishDedupMaxEntries = clampSetting(this.settings.eventPublishDedupMaxEntries, 100, 5000);
40+
this.topicCacheMaxEntries = clampSetting(this.settings.topicCacheMaxEntries, 100, 5000);
4041
this.eventPublishCoalesce = this.settings.eventPublishCoalesce === true;
4142
this._recentPublishes = new Map();
4243
this._topicCache = new Map();
@@ -388,7 +389,7 @@ class EventPublisher {
388389
};
389390

390391
if (this._topicCache.size >= this.topicCacheMaxEntries) {
391-
this._topicCache.delete(this._topicCache.keys().next().value);
392+
evictOldestFifo(this._topicCache);
392393
}
393394
this._topicCache.set(key, topics);
394395
this._publishStats.topicCacheMiss += 1;
@@ -411,9 +412,8 @@ class EventPublisher {
411412

412413
// Second pass: enforce max size by oldest insertion order.
413414
while (this._recentPublishes.size > this.eventPublishDedupMaxEntries) {
414-
const oldestKey = this._recentPublishes.keys().next().value;
415+
const oldestKey = evictOldestFifo(this._recentPublishes);
415416
if (oldestKey === undefined) break;
416-
this._recentPublishes.delete(oldestKey);
417417
this._publishStats.dedupEvicted += 1;
418418
}
419419
}

src/haBridgeDiagnostics.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const fs = require('fs');
22
const { createLogger } = require('./logger');
33
const { MQTT_TOPIC_STATUS, MQTT_RETAINED_STATE_OPTIONS, entityIdFields, HA_COMPONENT_SENSOR, HA_COMPONENT_BINARY_SENSOR, HA_DEVICE_VIA } = require('./constants');
4+
const { clampSetting } = require('./utils');
45

56
const CGATE_VERSION_FILE = '/data/cgate/.version';
67

@@ -19,7 +20,7 @@ class HaBridgeDiagnostics {
1920
return;
2021
}
2122

22-
const intervalSeconds = Math.max(10, Number(this.settings.ha_bridge_diagnostics_interval_sec) || 60);
23+
const intervalSeconds = clampSetting(this.settings.ha_bridge_diagnostics_interval_sec, 10, 60);
2324
if (this._intervalId) {
2425
clearInterval(this._intervalId);
2526
}

src/mqttManager.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const mqtt = require('mqtt');
22
const { EventEmitter } = require('events');
33
const { createLogger } = require('./logger');
44
const { createErrorHandler } = require('./errorHandler');
5+
const { evictOldestFifo } = require('./utils');
56
const {
67
MQTT_TOPIC_PREFIX_WRITE,
78
MQTT_TOPIC_STATUS,
@@ -161,8 +162,7 @@ class MqttManager extends EventEmitter {
161162
// Bounded queue: evict the oldest entry when over the cap. Map
162163
// iteration is insertion order so the first key is the oldest.
163164
if (this._pendingPublishQueue.size > this._pendingPublishMaxEntries) {
164-
const oldest = this._pendingPublishQueue.keys().next().value;
165-
this._pendingPublishQueue.delete(oldest);
165+
evictOldestFifo(this._pendingPublishQueue);
166166
this._pendingPublishEvicted += 1;
167167
}
168168
}

src/staleDeviceDetector.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { createLogger } = require('./logger');
22
const { MQTT_TOPIC_STATUS, MQTT_RETAINED_STATE_OPTIONS, entityIdFields, HA_COMPONENT_SENSOR, HA_DEVICE_VIA } = require('./constants');
3+
const { clampSetting } = require('./utils');
34

45
/**
56
* Periodically checks for C-Bus devices that have not reported a state change
@@ -44,7 +45,7 @@ class StaleDeviceDetector {
4445
return;
4546
}
4647

47-
const intervalSec = Math.max(60, Number(this.settings.stale_device_check_interval_sec) || 3600);
48+
const intervalSec = clampSetting(this.settings.stale_device_check_interval_sec, 60, 3600);
4849

4950
this._publishDiscovery();
5051
this._check();
@@ -73,7 +74,7 @@ class StaleDeviceDetector {
7374
*/
7475
_check() {
7576
try {
76-
const thresholdMs = Math.max(1, Number(this.settings.stale_device_threshold_hours) || 24) * 60 * 60 * 1000;
77+
const thresholdMs = clampSetting(this.settings.stale_device_threshold_hours, 1, 24) * 60 * 60 * 1000;
7778
const staleDevices = this._getStaleDevices(thresholdMs);
7879
this._publishStaleCount(staleDevices.length, staleDevices);
7980
} catch (error) {
@@ -120,7 +121,7 @@ class StaleDeviceDetector {
120121
* @private
121122
*/
122123
_publishStaleCount(count, staleDevices) {
123-
const thresholdHours = Math.max(1, Number(this.settings.stale_device_threshold_hours) || 24);
124+
const thresholdHours = clampSetting(this.settings.stale_device_threshold_hours, 1, 24);
124125

125126
this.mqttClient.publish('cbus/bridge/stale_devices', String(count), MQTT_RETAINED_STATE_OPTIONS);
126127

src/throttledQueue.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { createLogger } = require('./logger');
2+
const { clampSetting } = require('./utils');
23

34
class ThrottledQueue {
45
/**
@@ -123,7 +124,7 @@ class ThrottledQueue {
123124
if (this._timer !== null) {
124125
return;
125126
}
126-
const delay = Math.max(0, Number(delayMs) || 0);
127+
const delay = clampSetting(delayMs, 0, 0);
127128
this._timer = setTimeout(() => {
128129
this._timer = null;
129130
this._process();

src/utils.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Clamp a settings value to a minimum, falling back to a default when the
3+
* configured value is missing, NaN, or zero. Encodes the canonical pattern
4+
* for reading numeric settings (Math.max(floor, Number(value) || default))
5+
* used throughout the bridge.
6+
*
7+
* @param {*} value The raw configured value (any type; coerced via Number).
8+
* @param {number} floor Hard lower bound enforced regardless of value or default.
9+
* @param {number} defaultValue Value used when Number(value) is falsy (0, NaN, undefined, etc).
10+
* @returns {number}
11+
*/
12+
function clampSetting(value, floor, defaultValue) {
13+
return Math.max(floor, Number(value) || defaultValue);
14+
}
15+
16+
/**
17+
* Remove the oldest key from a Map (FIFO order matches insertion order in JS)
18+
* and return the evicted key. Used by the bounded caches across the bridge to
19+
* keep size at or below configured limits.
20+
*
21+
* @param {Map} map
22+
* @returns {*} the evicted key (undefined if map was empty)
23+
*/
24+
function evictOldestFifo(map) {
25+
const oldestKey = map.keys().next().value;
26+
map.delete(oldestKey);
27+
return oldestKey;
28+
}
29+
30+
module.exports = { clampSetting, evictOldestFifo };

tests/bridgeInitializationService.test.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,14 @@ describe('BridgeInitializationService', () => {
450450
const svc = new BridgeInitializationService(bridge);
451451
await svc.handleAllConnected();
452452

453-
// readiness fires first - then the rest of the post-connect work.
454-
expect(eventOrder[0]).toBe('readiness');
455-
expect(eventOrder).toContain('queue-add');
456-
expect(eventOrder).toContain('discovery-trigger');
453+
// Verify the actual ordering constraint (readiness before each
454+
// post-connect work item) rather than the brittle "readiness is
455+
// first" check - keeps the test robust if a future change adds
456+
// bookkeeping before readiness.
457+
const readinessIdx = eventOrder.indexOf('readiness');
458+
expect(readinessIdx).toBeGreaterThanOrEqual(0);
459+
expect(readinessIdx).toBeLessThan(eventOrder.indexOf('queue-add'));
460+
expect(readinessIdx).toBeLessThan(eventOrder.indexOf('discovery-trigger'));
457461
});
458462

459463
it('passes working publish callback to HaDiscovery', async () => {

tests/utils.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const { clampSetting, evictOldestFifo } = require('../src/utils');
2+
3+
describe('clampSetting', () => {
4+
it('uses default when value is undefined', () => {
5+
expect(clampSetting(undefined, 100, 5000)).toBe(5000);
6+
});
7+
8+
it('uses default when value is 0 (treated as "not configured")', () => {
9+
expect(clampSetting(0, 100, 5000)).toBe(5000);
10+
});
11+
12+
it('uses default when value is NaN', () => {
13+
expect(clampSetting(NaN, 100, 5000)).toBe(5000);
14+
});
15+
16+
it('returns configured value when above floor', () => {
17+
expect(clampSetting(2000, 100, 5000)).toBe(2000);
18+
});
19+
20+
it('clamps to floor when configured value is below it', () => {
21+
expect(clampSetting(50, 100, 5000)).toBe(100);
22+
});
23+
24+
it('coerces string values via Number()', () => {
25+
expect(clampSetting('3000', 100, 5000)).toBe(3000);
26+
});
27+
28+
it('clamps to floor when default is below floor', () => {
29+
expect(clampSetting(undefined, 100, 0)).toBe(100);
30+
});
31+
});
32+
33+
describe('evictOldestFifo', () => {
34+
it('removes and returns the oldest inserted key', () => {
35+
const m = new Map([['a', 1], ['b', 2], ['c', 3]]);
36+
expect(evictOldestFifo(m)).toBe('a');
37+
expect([...m.keys()]).toEqual(['b', 'c']);
38+
});
39+
40+
it('returns undefined for an empty map (no-op)', () => {
41+
const m = new Map();
42+
expect(evictOldestFifo(m)).toBeUndefined();
43+
expect(m.size).toBe(0);
44+
});
45+
46+
it('FIFO order matches insertion order even after updates', () => {
47+
const m = new Map();
48+
m.set('a', 1);
49+
m.set('b', 2);
50+
m.set('a', 99); // update in place; does NOT move to end
51+
expect(evictOldestFifo(m)).toBe('a');
52+
});
53+
});

0 commit comments

Comments
 (0)