@winkjs/composer 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/composer.js +1 -1
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +103 -658
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/index.js +16 -1
- package/src/core/env-vars.js +92 -24
- package/src/core/logger/index.js +1 -1
- package/src/core/source-manager/mqtt/client.js +197 -63
- package/src/core/source-manager/mqtt/dedup.js +16 -11
- package/src/core/source-manager/mqtt/index.js +32 -5
- package/src/core/source-manager/mqtt/status.js +168 -56
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +542 -862
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +266 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +23 -2
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/wiring/wire-emitters.js +3 -1
- package/src/core/wiring/wire-storages.js +10 -2
- package/src/flow/flow.js +24 -3
- package/src/flow/run.js +24 -2
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
* closes that gap by construction; the composer-owned WAL that restores
|
|
16
16
|
* crash durability is ADR-021's planned successor.
|
|
17
17
|
*
|
|
18
|
+
* The adapter is one closure factory per concern, the shape QuestDB's
|
|
19
|
+
* flush engine uses. This file is the factory. It resolves the
|
|
20
|
+
* configuration, builds the state, opens the client, and assembles the
|
|
21
|
+
* handle from the modules beside it:
|
|
22
|
+
* - `resolve-config.js` validates every option in one fixed order and
|
|
23
|
+
* arms the three callbacks through the shared guard.
|
|
24
|
+
* - `connection.js` opens the client, waits for the first connack, and
|
|
25
|
+
* keeps `state.connected` and the counters current.
|
|
26
|
+
* - `health.js` reads pressure and health, and signals backpressure.
|
|
27
|
+
* - `publish.js` is the hot path, `publishNow`.
|
|
28
|
+
* - `shutdown-drain.js` is the latched drain-then-close shutdown.
|
|
29
|
+
* Each module captures what it needs at setup. Nothing moves onto a
|
|
30
|
+
* per-message argument list.
|
|
31
|
+
*
|
|
18
32
|
* DESIGN DECISIONS:
|
|
19
33
|
*
|
|
20
34
|
* 1. FIRE-AND-FORGET WITH QoS 1
|
|
@@ -37,6 +51,12 @@
|
|
|
37
51
|
* 3. MESSAGE EXPIRY (MQTT v5)
|
|
38
52
|
* - Configurable per message type
|
|
39
53
|
* - Prevents flooding backend after extended offline periods
|
|
54
|
+
* - The type lookup is a null-prototype table. A prototype key such
|
|
55
|
+
* as 'constructor' would hand the library a function, and
|
|
56
|
+
* mqtt-packet then destroys the stream with an error that carries
|
|
57
|
+
* no code. mqtt.js swallows such an error, so no handler sees it,
|
|
58
|
+
* and the poison packet replays on every reconnect. An unknown
|
|
59
|
+
* key falls back to the default expiry instead.
|
|
40
60
|
*
|
|
41
61
|
* 4. NO FORCED IDENTITY / OPTIONAL WILL — unchanged transport behavior.
|
|
42
62
|
*
|
|
@@ -48,8 +68,10 @@
|
|
|
48
68
|
* reachable broker the real wait is one connack round trip; on an
|
|
49
69
|
* unreachable one the flow starts with `connected: false` and the
|
|
50
70
|
* client keeps retrying in the background.
|
|
51
|
-
* - Return shape:
|
|
52
|
-
*
|
|
71
|
+
* - Return shape: always a Promise of the handle, as QuestDB's
|
|
72
|
+
* factory returns. At grace 0 the promise resolves without a
|
|
73
|
+
* wait. A configuration refusal arrives as a rejection carrying
|
|
74
|
+
* the classified code. The wiring layer awaits it (ADR-018).
|
|
53
75
|
* - This replaced the wire-time `sleep(240)` that once papered over
|
|
54
76
|
* the pre-connack loss ADR-021 eliminated.
|
|
55
77
|
*
|
|
@@ -61,7 +83,12 @@
|
|
|
61
83
|
* when supplied (ctx = `{ topic }`); without a handler the adapter
|
|
62
84
|
* surfaces the failure via `Promise.reject(deliveryErr)` — loud failure
|
|
63
85
|
* beats silent loss (parity with QuestDB). `onCritical` is
|
|
64
|
-
* reserved for the high-pressure `QUEUE_CRITICAL` warning (no loss
|
|
86
|
+
* reserved for the high-pressure `QUEUE_CRITICAL` warning (no loss
|
|
87
|
+
* yet). It fires once per climb past 0.8 and re-arms below 0.66;
|
|
88
|
+
* `onBackpressure` reports the pressure after every acknowledgment.
|
|
89
|
+
* - `flush( { timeout }? )` waits for every accepted message to settle,
|
|
90
|
+
* closing nothing and refusing nothing. On its deadline it rejects
|
|
91
|
+
* `DELIVERY_FAILED` with `pending: { count }` (ADR-018 §6).
|
|
65
92
|
* - `getPressure()` returns unacked / maxQueueSize in `[0, 1]` (sync,
|
|
66
93
|
* O(1), allocation-free per ADR-018).
|
|
67
94
|
* - `getHealth()` returns the ADR-018 health floor `{status, connected,
|
|
@@ -100,7 +127,12 @@
|
|
|
100
127
|
* - `SHUTTING_DOWN` — emitter is mid-shutdown; new publishes are dropped.
|
|
101
128
|
* - `INVALID_CONFIG` — setup-time; missing or malformed config field
|
|
102
129
|
* (brokerUrl, codec.pack, callback type, connectGraceMs). On the
|
|
103
|
-
* thrown TypeError per ADR-018's fail-fast setup rule.
|
|
130
|
+
* thrown TypeError per ADR-018's fail-fast setup rule. Includes a
|
|
131
|
+
* `brokerUrl` whose host is `localhost` (ADR-030): the name can
|
|
132
|
+
* resolve to two addresses and the broker may listen on only one.
|
|
133
|
+
* The message names the literal to set. Refused before the client
|
|
134
|
+
* is created, at the schema (flow definition) and here (the
|
|
135
|
+
* `MQTT_BROKER_URL` fallback and direct callers).
|
|
104
136
|
* - `SHUTDOWN_TIMEOUT` — shutdown closed with unacknowledged messages;
|
|
105
137
|
* carries `dropped: { count }` (the exact counter value). Fires whether
|
|
106
138
|
* connected or not: with no disk store, nothing survives the process,
|
|
@@ -113,240 +145,49 @@
|
|
|
113
145
|
* keeps publishing and each fault becomes one classified console
|
|
114
146
|
* line. Fix the callback; the line names it and carries the detail.
|
|
115
147
|
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
import { wrapCallback } from '../../utils/callback-guard/index.js';
|
|
132
|
-
import { logger } from '../../logger/index.js';
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Pre-flight reject threshold on store pressure.
|
|
136
|
-
* 0.9 = 10% headroom for in-flight drain on shutdown, plus a clean band
|
|
137
|
-
* between the yellow-health threshold (0.66, shared with the yield level the
|
|
138
|
-
* ADR-020 Draft proposes) and store exhaustion (1.0). At or above this,
|
|
139
|
-
* `publishNow` returns `STORAGE_FULL` synchronously; the message is not buffered.
|
|
140
|
-
* @type {number}
|
|
141
|
-
*/
|
|
142
|
-
const STORAGE_PRESSURE_LIMIT = 0.9;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Pressure threshold above which `getHealth().status` elevates to at least
|
|
146
|
-
* 'yellow' (uniform with Terminal/QuestDB). The value matches the
|
|
147
|
-
* pressure-aware-yield design (ADR-020, still a Draft). That design
|
|
148
|
-
* proposes 0.66 as the pressure level where the flow would start yielding
|
|
149
|
-
* to let sinks drain; a benchmark still has to confirm the number. Today
|
|
150
|
-
* the yield trigger is time-only (ADR-024), so this alignment is
|
|
151
|
-
* forward-looking.
|
|
152
|
-
* @type {number}
|
|
153
|
-
*/
|
|
154
|
-
const HEALTH_PRESSURE_YELLOW_THRESHOLD = 0.66;
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Singleton success result reused on every successful publish. Hot-path zero
|
|
158
|
-
* allocation per ADR-013 / ADR-004. Plain literal — not frozen.
|
|
159
|
-
* @type {{ok: true}}
|
|
160
|
-
*/
|
|
161
|
-
const RESULT_OK = { ok: true };
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Singleton error result for the pre-flight pressure-limit reject. Reused on
|
|
165
|
-
* every occurrence; rare in healthy operation (pressure this close to the
|
|
166
|
-
* limit means delivery has stopped draining), but a defined contract path.
|
|
167
|
-
* @type {{ok: false, error: {code: string, message: string}}}
|
|
168
|
-
*/
|
|
169
|
-
const ERR_STORAGE_FULL = {
|
|
170
|
-
ok: false,
|
|
171
|
-
error: {
|
|
172
|
-
code: 'STORAGE_FULL',
|
|
173
|
-
message: 'Store at or above pressure limit (0.9) — cannot accept message'
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Singleton error result for publishes attempted while shutdown is in progress.
|
|
179
|
-
* @type {{ok: false, error: {code: string, message: string}}}
|
|
180
|
-
*/
|
|
181
|
-
const ERR_SHUTTING_DOWN = {
|
|
182
|
-
ok: false,
|
|
183
|
-
error: {
|
|
184
|
-
code: 'SHUTTING_DOWN',
|
|
185
|
-
message: 'Emitter is shutting down — message dropped'
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Build a config-error TypeError tagged for ADR-018 fail-fast setup routing.
|
|
191
|
-
* @param {string} message
|
|
192
|
-
* @returns {TypeError}
|
|
193
|
-
*/
|
|
194
|
-
const invalidConfig = function ( message ) {
|
|
195
|
-
const err = new TypeError( 'winkComposer/mqttEmitter: ' + message );
|
|
196
|
-
err.code = 'INVALID_CONFIG';
|
|
197
|
-
return err;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Throw classified when an optional callback option is set to a non-function.
|
|
202
|
-
* @param {*} value - the configured option value
|
|
203
|
-
* @param {string} name - the option name for the error message
|
|
204
|
-
*/
|
|
205
|
-
const assertOptionalCallback = function ( value, name ) {
|
|
206
|
-
if ( value !== undefined && typeof value !== 'function' ) {
|
|
207
|
-
throw invalidConfig( `${name} must be a function` );
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Validate and build the mqtt.js will (last-will testament) options.
|
|
213
|
-
*
|
|
214
|
-
* Direct createEmitter callers bypass the DSL schema, so the factory
|
|
215
|
-
* checks the will shape itself, and the payload encoding runs behind
|
|
216
|
-
* the same classified-throw rule as every other setup failure
|
|
217
|
-
* (ADR-018, fail-fast setup) — a raw codec TypeError would reach the
|
|
218
|
-
* caller unclassified.
|
|
148
|
+
* Console classification (a token on a log line, not an `err.code`):
|
|
149
|
+
* - `DELIVERY_HEALTH` — the link changed state. One line per change,
|
|
150
|
+
* with or without `debug`: going offline at error, the next connack
|
|
151
|
+
* at warn with the outage length. Both carry the in-flight count.
|
|
152
|
+
* Nothing prints while a state persists (see `connection.js`).
|
|
153
|
+
* - `CONNECT_FAILED` — the client's `error` event, at warn, with
|
|
154
|
+
* the reason (a refused connect, a connack timeout). Bounded: two
|
|
155
|
+
* per episode in full, then one summary a minute.
|
|
156
|
+
* - `ADDRESS_IS_NAME` — the `brokerUrl` host is a name other than
|
|
157
|
+
* `localhost`. One `logger.warn` line at setup, before the client is
|
|
158
|
+
* created (ADR-030). A name is allowed and the emitter proceeds; the
|
|
159
|
+
* line tells the operator that only a literal address is immune to a
|
|
160
|
+
* resolver that changes its answer under a running process. This
|
|
161
|
+
* emitter refuses and warns; it does not probe, because its posture
|
|
162
|
+
* is recovering (ADR-018 §5).
|
|
219
163
|
*
|
|
220
|
-
* @
|
|
221
|
-
* @param {Object} codec - config.codec (pack + contentType)
|
|
222
|
-
* @returns {Object} the mqtt.js connect-options `will` object
|
|
164
|
+
* @module mqtt-emitter
|
|
223
165
|
*/
|
|
224
|
-
const buildWillOptions = function ( will, codec ) {
|
|
225
|
-
if ( typeof will.topic !== 'string' || will.topic.length === 0 ) {
|
|
226
|
-
throw invalidConfig( 'will.topic is required — a non-empty string' );
|
|
227
|
-
}
|
|
228
|
-
if ( will.message === undefined || will.message === null ) {
|
|
229
|
-
throw invalidConfig( 'will.message is required' );
|
|
230
|
-
}
|
|
231
|
-
let willPayload;
|
|
232
|
-
try {
|
|
233
|
-
willPayload = Buffer.from( codec.pack( will.message ) );
|
|
234
|
-
} catch ( packErr ) {
|
|
235
|
-
throw invalidConfig( `will.message could not be encoded by the codec: ${packErr.message}` );
|
|
236
|
-
}
|
|
237
|
-
const willOptions = {
|
|
238
|
-
topic: will.topic,
|
|
239
|
-
payload: willPayload,
|
|
240
|
-
// `??`, not `||`: 0 is a valid QoS.
|
|
241
|
-
qos: will.qos ?? QOS,
|
|
242
|
-
retain: will.retain !== false, // Default true
|
|
243
|
-
properties: {
|
|
244
|
-
contentType: codec.contentType
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
if ( codec.payloadFormatIndicator === 1 ) {
|
|
248
|
-
willOptions.properties.payloadFormatIndicator = true;
|
|
249
|
-
}
|
|
250
|
-
return willOptions;
|
|
251
|
-
};
|
|
252
166
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
* a working handle that reports `connected: false` while mqtt.js
|
|
259
|
-
* retries in the background. The one-shot listener is removed on
|
|
260
|
-
* expiry so a late connack reaches only the permanent state handler;
|
|
261
|
-
* the timer is cleared on connect so nothing pins the event loop.
|
|
262
|
-
*
|
|
263
|
-
* @param {Object} client - mqtt.js client (EventEmitter surface)
|
|
264
|
-
* @param {number} graceMs - positive wait budget, milliseconds
|
|
265
|
-
* @returns {Promise<void>} resolves on first connect or expiry; never rejects
|
|
266
|
-
*/
|
|
267
|
-
const waitForFirstConnect = function ( client, graceMs ) {
|
|
268
|
-
return new Promise( ( resolve ) => {
|
|
269
|
-
let timer = null;
|
|
270
|
-
const onConnect = function () {
|
|
271
|
-
clearTimeout( timer );
|
|
272
|
-
resolve();
|
|
273
|
-
};
|
|
274
|
-
timer = setTimeout( () => {
|
|
275
|
-
client.removeListener( 'connect', onConnect );
|
|
276
|
-
resolve();
|
|
277
|
-
}, graceMs );
|
|
278
|
-
client.once( 'connect', onConnect );
|
|
279
|
-
} );
|
|
280
|
-
}; // waitForFirstConnect()
|
|
167
|
+
import { resolveConfig } from './resolve-config.js';
|
|
168
|
+
import { openClient, waitForFirstConnect, attachConnectionHandlers } from './connection.js';
|
|
169
|
+
import { createHealth } from './health.js';
|
|
170
|
+
import { createPublishNow } from './publish.js';
|
|
171
|
+
import { createShutdown } from './shutdown-drain.js';
|
|
281
172
|
|
|
282
173
|
/**
|
|
283
174
|
* Create production MQTT emitter.
|
|
284
175
|
*
|
|
285
|
-
* Validation
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* The wiring layer awaits
|
|
176
|
+
* Validation runs before any side effect. Bad config rejects the
|
|
177
|
+
* returned promise with a classified `INVALID_CONFIG` error, so a
|
|
178
|
+
* refusal and a handle arrive through the same channel, the way
|
|
179
|
+
* QuestDB's factory works. With a positive grace the promise resolves
|
|
180
|
+
* after the first connack or the grace budget; at grace 0 it resolves
|
|
181
|
+
* without a wait. The wiring layer awaits it (ADR-018).
|
|
291
182
|
*
|
|
292
183
|
* @param {Object} config - emitter config (see configSchema in index.js)
|
|
293
|
-
* @returns {
|
|
184
|
+
* @returns {Promise<Object>} the emitter handle
|
|
294
185
|
*/
|
|
295
|
-
export const createEmitter = function ( config ) {
|
|
296
|
-
// Per ADR-018, setup-time
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
// which throws on `ilpUrl: ''`.
|
|
301
|
-
const rawBrokerUrl = config.brokerUrl ?? ENV_VARS.mqttBrokerUrl;
|
|
302
|
-
const brokerUrl = typeof rawBrokerUrl === 'string' ? rawBrokerUrl.trim() : '';
|
|
303
|
-
if ( !brokerUrl ) {
|
|
304
|
-
throw invalidConfig( 'brokerUrl required — set a non-empty string in .emitter() config or MQTT_BROKER_URL env var' );
|
|
305
|
-
}
|
|
306
|
-
if ( !config.codec ) {
|
|
307
|
-
throw invalidConfig( 'config.codec is required' );
|
|
308
|
-
}
|
|
309
|
-
if ( typeof config.codec.pack !== 'function' ) {
|
|
310
|
-
throw invalidConfig( 'config.codec must have a pack() function' );
|
|
311
|
-
}
|
|
312
|
-
assertOptionalCallback( config.onDeliveryFailure, 'onDeliveryFailure' );
|
|
313
|
-
assertOptionalCallback( config.onCritical, 'onCritical' );
|
|
314
|
-
assertOptionalCallback( config.onBackpressure, 'onBackpressure' );
|
|
315
|
-
|
|
316
|
-
// The three notification callbacks are armed by the shared
|
|
317
|
-
// callback guard. They were validated raw above and are wrapped
|
|
318
|
-
// once here. The wrap matters because all three run inside
|
|
319
|
-
// mqtt.js's publish ack chain, where a user throw would land in
|
|
320
|
-
// the client library (ADR-018). Each wrap is null when the
|
|
321
|
-
// callback is absent, so every no-handler path below keeps its
|
|
322
|
-
// exact meaning. That includes the deliberate unhandled-rejection
|
|
323
|
-
// escape hatch for an unhandled delivery failure.
|
|
324
|
-
const reportCallbackFault = function ( severity, name, detail ) {
|
|
325
|
-
logger.error(
|
|
326
|
-
`winkComposer/mqttEmitter: user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
|
|
327
|
-
);
|
|
328
|
-
};
|
|
329
|
-
const onDeliveryFailure = wrapCallback( config.onDeliveryFailure, {
|
|
330
|
-
name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
|
|
331
|
-
} );
|
|
332
|
-
const onCritical = wrapCallback( config.onCritical, {
|
|
333
|
-
name: 'onCritical', severity: 'red', report: reportCallbackFault
|
|
334
|
-
} );
|
|
335
|
-
const onBackpressure = wrapCallback( config.onBackpressure, {
|
|
336
|
-
name: 'onBackpressure', severity: 'yellow', report: reportCallbackFault
|
|
337
|
-
} );
|
|
338
|
-
|
|
339
|
-
// First-connack grace: explicit config → MQTT_CONNECT_GRACE_MS env
|
|
340
|
-
// fallback → 500 ms default (ADR-018 precedence; the fallback lives
|
|
341
|
-
// here in the factory body, never as a schema sigil). `??` keeps an
|
|
342
|
-
// explicit 0 — "hand the handle back immediately" — from falling
|
|
343
|
-
// through to the env value. Number.isInteger also rejects Infinity,
|
|
344
|
-
// so the wait is bounded by construction. Validated here, before
|
|
345
|
-
// mqtt.connect opens anything (fail-fast setup per ADR-018).
|
|
346
|
-
const connectGraceMs = config.connectGraceMs ?? ENV_VARS.mqttConnectGraceMs;
|
|
347
|
-
if ( !Number.isInteger( connectGraceMs ) || connectGraceMs < 0 ) {
|
|
348
|
-
throw invalidConfig( 'connectGraceMs must be a non-negative integer (milliseconds); 0 disables the first-connect wait' );
|
|
349
|
-
}
|
|
186
|
+
export const createEmitter = async function ( config ) {
|
|
187
|
+
// Per ADR-018, setup-time refusals carry a classified err.code and
|
|
188
|
+
// run before any side effect. `resolveConfig` throws in one fixed
|
|
189
|
+
// order, and this async function turns each throw into a rejection.
|
|
190
|
+
const resolved = resolveConfig( config );
|
|
350
191
|
|
|
351
192
|
// Core state. `unacked` is THE counter (ADR-021): +1 on accepted
|
|
352
193
|
// publish, -1 when the publish callback fires. Pressure, refusal,
|
|
@@ -365,446 +206,50 @@ export const createEmitter = function ( config ) {
|
|
|
365
206
|
}
|
|
366
207
|
};
|
|
367
208
|
|
|
368
|
-
|
|
369
|
-
// logs print this redacted form, so a debug flag flipped on in
|
|
370
|
-
// production cannot leak the password into whatever collects stdout.
|
|
371
|
-
const redactedBrokerUrl = brokerUrl.replace( /\/\/[^@/]*@/, '//***@' );
|
|
372
|
-
|
|
373
|
-
// Generate unique client ID if not provided
|
|
374
|
-
const clientId = config.clientId || `wink-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 9 )}`;
|
|
375
|
-
|
|
376
|
-
// The unacked cap. Clamped to the 16-bit packet-id space: every
|
|
377
|
-
// unacknowledged QoS-1 message holds a packet id, so one connection
|
|
378
|
-
// can never carry more than 65,535; 60,000 leaves working headroom
|
|
379
|
-
// (same clamp the LevelDB store applied, kept with the same warning).
|
|
380
|
-
const requestedQueueSize = config.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
|
|
381
|
-
if ( requestedQueueSize > MQTT_INFLIGHT_ID_LIMIT ) {
|
|
382
|
-
logger.warn(
|
|
383
|
-
`winkComposer/mqttEmitter: maxQueueSize ${requestedQueueSize} exceeds the MQTT ` +
|
|
384
|
-
`packet-id ceiling — clamped to ${MQTT_INFLIGHT_ID_LIMIT}`
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
const maxQueueSize = Math.min( requestedQueueSize, MQTT_INFLIGHT_ID_LIMIT );
|
|
388
|
-
|
|
389
|
-
// Build MQTT options.
|
|
390
|
-
//
|
|
391
|
-
// NO outgoingStore: the client runs its default SYNCHRONOUS memory
|
|
392
|
-
// store (ADR-021). mqtt.js erases its packet-id bookkeeping on every
|
|
393
|
-
// connack and rebuilds it from a store snapshot asynchronously; with
|
|
394
|
-
// an asynchronous store, writes still in flight at that instant are
|
|
395
|
-
// invisible to the snapshot and get overwritten by id reuse — real
|
|
396
|
-
// QoS-1 loss, measured scaling with store write latency. The
|
|
397
|
-
// synchronous store makes the gap zero-width (validated 2026-07-09,
|
|
398
|
-
// nine-run matrix incl. reconnects at 45k in flight).
|
|
399
|
-
//
|
|
400
|
-
// messageIdProvider: the client's default provider cycles the 16-bit
|
|
401
|
-
// id space with NO in-use check — at 14 k msg/s it wraps every ~5 s,
|
|
402
|
-
// and an id whose PUBACK never arrived gets reassigned, overwriting
|
|
403
|
-
// the unacked packet's memory-store entry (same key). Measured at
|
|
404
|
-
// about one lost publish per 700,000 under sustained load.
|
|
405
|
-
// UniqueMessageIdProvider never reissues an in-use id.
|
|
406
|
-
const mqttOptions = {
|
|
407
|
-
...MQTT_CONFIG,
|
|
408
|
-
clientId,
|
|
409
|
-
messageIdProvider: new mqtt.UniqueMessageIdProvider()
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
// Only add will if explicitly provided (validated + encoded by the
|
|
413
|
-
// helper above; classified throws per ADR-018)
|
|
414
|
-
if ( config.will ) {
|
|
415
|
-
mqttOptions.will = buildWillOptions( config.will, config.codec );
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Create MQTT client (injectable for testing). mqtt.connect throws
|
|
419
|
-
// its own raw TypeError on a malformed url; classify it like every
|
|
420
|
-
// other setup failure. The error text uses the redacted url — a
|
|
421
|
-
// malformed url can still carry credentials.
|
|
422
|
-
const connect = config.mqttConnectFn || mqtt.connect;
|
|
423
|
-
let client;
|
|
424
|
-
try {
|
|
425
|
-
client = connect( brokerUrl, mqttOptions );
|
|
426
|
-
} catch ( connectErr ) {
|
|
427
|
-
throw invalidConfig( `brokerUrl rejected by the MQTT client ('${redactedBrokerUrl}'): ${connectErr.message}` );
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Fill ratio of the unacked window: unacked / maxQueueSize, capped
|
|
432
|
-
* at 1. Sync, O(1), allocation-free (ADR-018).
|
|
433
|
-
* @returns {number}
|
|
434
|
-
*/
|
|
435
|
-
const getPressure = function () {
|
|
436
|
-
const ratio = state.unacked / maxQueueSize;
|
|
437
|
-
return ratio > 1 ? 1 : ratio;
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Check and signal backpressure
|
|
442
|
-
*/
|
|
443
|
-
const checkBackpressure = function () {
|
|
444
|
-
const pressure = getPressure();
|
|
445
|
-
|
|
446
|
-
if ( ( pressure > QUEUE_CRITICAL_THRESHOLD ) && onCritical ) {
|
|
447
|
-
onCritical( 'QUEUE_CRITICAL', pressure );
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if ( onBackpressure ) {
|
|
451
|
-
onBackpressure( pressure );
|
|
452
|
-
}
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Publish message immediately (fire-and-forget).
|
|
457
|
-
*
|
|
458
|
-
* Sync return per the ADR-018 sink contract:
|
|
459
|
-
* - `{ ok: true }` on successful buffer.
|
|
460
|
-
* - `{ ok: false, error: { code: 'STORAGE_FULL', message } }` if pre-flight
|
|
461
|
-
* pressure check rejects (pressure >= STORAGE_PRESSURE_LIMIT).
|
|
462
|
-
* - `{ ok: false, error: { code: 'SHUTTING_DOWN', message } }` if called
|
|
463
|
-
* while shutdown is in progress.
|
|
464
|
-
*
|
|
465
|
-
* Async store-write or publish failures (after the message was buffered)
|
|
466
|
-
* route through `onDeliveryFailure(err, { topic })` when supplied; without
|
|
467
|
-
* a handler the adapter surfaces the failure via `Promise.reject`,
|
|
468
|
-
* which Node logs as unhandledRejection (and v15+ exits on). Loud failure
|
|
469
|
-
* beats silent loss.
|
|
470
|
-
*
|
|
471
|
-
* @param {string} topic - MQTT topic
|
|
472
|
-
* @param {*} message - Message payload (will be encoded via config.codec)
|
|
473
|
-
* @param {Object} [options] - Per-message options
|
|
474
|
-
* @param {string} [options.type] - Message-type key for MESSAGE_EXPIRY lookup
|
|
475
|
-
* @returns {{ok: true} | {ok: false, error: {code: string, message: string}}}
|
|
476
|
-
*/
|
|
477
|
-
const publishNow = function ( topic, message, options ) {
|
|
478
|
-
if ( state.shuttingDown ) {
|
|
479
|
-
return ERR_SHUTTING_DOWN;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Pre-flight pressure check — reject above STORAGE_PRESSURE_LIMIT to
|
|
483
|
-
// leave headroom for in-flight drain and to surface backpressure to
|
|
484
|
-
// producers before the store is literally full.
|
|
485
|
-
if ( getPressure() >= STORAGE_PRESSURE_LIMIT ) {
|
|
486
|
-
return ERR_STORAGE_FULL;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Encode BEFORE the counter moves. A message the codec cannot
|
|
490
|
-
// encode was never in flight, so it must not occupy a slot: a
|
|
491
|
-
// leaked slot never drains, pressure ratchets up, and the
|
|
492
|
-
// emitter ends up refusing everything. The
|
|
493
|
-
// refusal is synchronous and classified; building it allocates,
|
|
494
|
-
// which is fine on an error path this rare.
|
|
495
|
-
let payload;
|
|
496
|
-
try {
|
|
497
|
-
payload = config.codec.pack( message );
|
|
498
|
-
} catch ( packErr ) {
|
|
499
|
-
state.stats.encodeErrors += 1;
|
|
500
|
-
return {
|
|
501
|
-
ok: false,
|
|
502
|
-
error: {
|
|
503
|
-
code: 'ENCODE_ERROR',
|
|
504
|
-
message: `Message could not be encoded (topic=${topic}): ${packErr.message}`
|
|
505
|
-
}
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const dedupId = crypto.randomUUID();
|
|
510
|
-
const messageType = ( options && options.type ) || 'default';
|
|
511
|
-
const expiry = MESSAGE_EXPIRY[ messageType ] || MESSAGE_EXPIRY.default;
|
|
512
|
-
|
|
513
|
-
// Per-message allocations from here to the publish call (the
|
|
514
|
-
// uuid, the properties object, the callback closure) cannot be
|
|
515
|
-
// pre-allocated and reused: mqtt.js keeps the packet — properties
|
|
516
|
-
// included — until PUBACK so it can retransmit, and a shared
|
|
517
|
-
// mutated object would corrupt every queued retransmission (the
|
|
518
|
-
// unavoidable-residual justification ADR-018 asks for).
|
|
519
|
-
const properties = {
|
|
520
|
-
messageExpiryInterval: expiry,
|
|
521
|
-
userProperties: {
|
|
522
|
-
[ WINK_NAMESPACE.dedupId ]: dedupId,
|
|
523
|
-
[ WINK_NAMESPACE.timestamp ]: Date.now().toString(),
|
|
524
|
-
[ WINK_NAMESPACE.version ]: '1.0'
|
|
525
|
-
},
|
|
526
|
-
contentType: config.codec.contentType
|
|
527
|
-
};
|
|
528
|
-
|
|
529
|
-
if ( config.codec.payloadFormatIndicator === 1 ) {
|
|
530
|
-
properties.payloadFormatIndicator = true;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Accept: the message is now in flight. The counter rises HERE,
|
|
534
|
-
// synchronously with the accept decision, so the very next
|
|
535
|
-
// publishNow call sees the true pressure (the old design's
|
|
536
|
-
// optimistic-increment lesson, kept).
|
|
537
|
-
state.unacked += 1;
|
|
538
|
-
|
|
539
|
-
// Fire-and-forget publish with QoS 1. Outcome of this async callback is
|
|
540
|
-
// observability-only — the publishNow caller has already received a
|
|
541
|
-
// synchronous result. The callback fires exactly once per publish
|
|
542
|
-
// (acknowledgment or failure), so the decrement below is the
|
|
543
|
-
// counter's single exit — paired with the catch, which releases
|
|
544
|
-
// the slot when the client rejects the call before accepting it.
|
|
545
|
-
try {
|
|
546
|
-
client.publish(
|
|
547
|
-
topic,
|
|
548
|
-
payload,
|
|
549
|
-
{ qos: QOS, properties },
|
|
550
|
-
( err ) => {
|
|
551
|
-
state.unacked -= 1;
|
|
552
|
-
if ( err ) {
|
|
553
|
-
state.stats.publishErrors += 1;
|
|
554
|
-
const deliveryErr = new Error(
|
|
555
|
-
`winkComposer/mqttEmitter: publish failed (topic=${topic}): ${err.message || err.code || 'unknown'}`
|
|
556
|
-
);
|
|
557
|
-
deliveryErr.code = 'DELIVERY_FAILED';
|
|
558
|
-
deliveryErr.cause = err;
|
|
559
|
-
if ( onDeliveryFailure ) {
|
|
560
|
-
onDeliveryFailure( deliveryErr, { topic } );
|
|
561
|
-
} else {
|
|
562
|
-
// No handler — surface as unhandledRejection so the
|
|
563
|
-
// process logs loudly and (Node 15+) exits. Mirrors
|
|
564
|
-
// QuestDB's persist-plan.js catch-and-throw: both
|
|
565
|
-
// adapters route undeclared async failures
|
|
566
|
-
// through the same Node-level escape hatch.
|
|
567
|
-
Promise.reject( deliveryErr );
|
|
568
|
-
}
|
|
569
|
-
} else {
|
|
570
|
-
state.stats.published += 1;
|
|
571
|
-
}
|
|
572
|
-
checkBackpressure();
|
|
573
|
-
}
|
|
574
|
-
);
|
|
575
|
-
} catch ( publishErr ) {
|
|
576
|
-
// The client threw before accepting the message; its callback
|
|
577
|
-
// will never fire, so the slot is released here — the sync
|
|
578
|
-
// face of DELIVERY_FAILED (see the header vocabulary).
|
|
579
|
-
state.unacked -= 1;
|
|
580
|
-
state.stats.publishErrors += 1;
|
|
581
|
-
return {
|
|
582
|
-
ok: false,
|
|
583
|
-
error: {
|
|
584
|
-
code: 'DELIVERY_FAILED',
|
|
585
|
-
message: `MQTT publish rejected (topic=${topic}): ${publishErr.message}`
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
return RESULT_OK;
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
// Shutdown outcome, latched on the first call. A second
|
|
594
|
-
// caller used to get an instant clean resolve — mid-drain, or even
|
|
595
|
-
// after a shutdown that recorded dropped messages. Every caller now
|
|
596
|
-
// receives the first call's promise.
|
|
597
|
-
let shutdownPromise = null;
|
|
598
|
-
|
|
599
|
-
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
|
|
600
|
-
|
|
601
|
-
/**
|
|
602
|
-
* The real shutdown body. `shutdown` below latches its promise so
|
|
603
|
-
* every caller receives this one outcome.
|
|
604
|
-
*
|
|
605
|
-
* **Drain-then-close** (a sustained-load finding, kept through the
|
|
606
|
-
* ADR-021 rework): mqtt.js's own `end()` does not reliably drain
|
|
607
|
-
* in-flight publishes, so the emitter waits on its own counter.
|
|
608
|
-
*
|
|
609
|
-
* 1. Sets `state.shuttingDown` so `publishNow` rejects new work
|
|
610
|
-
* (returns `SHUTTING_DOWN` immediately).
|
|
611
|
-
* 2. **Polls the unacked counter until zero**, using the whole
|
|
612
|
-
* `timeout` budget — no early give-up. The wait runs whether or
|
|
613
|
-
* not the connection is up at the moment of the call: mqtt.js
|
|
614
|
-
* auto-reconnects, so a mid-drain reconnect can still deliver
|
|
615
|
-
* the backlog inside the budget.
|
|
616
|
-
* 3. Once drained (or budget expired), calls `client.end(...)`
|
|
617
|
-
* with the remaining time as the broker-DISCONNECT budget.
|
|
618
|
-
* 4. Force-closes if the broker hangs the DISCONNECT.
|
|
619
|
-
*
|
|
620
|
-
* The old design's shutdown re-drive (re-sending stragglers through
|
|
621
|
-
* a store stream scan) died with the disk store: with the memory
|
|
622
|
-
* store there is no store to scan, and a message whose PUBACK was
|
|
623
|
-
* lost in-session is re-sent by the client itself on the next
|
|
624
|
-
* reconnect. If it is still unacknowledged at the deadline, the
|
|
625
|
-
* loss report counts it.
|
|
626
|
-
*
|
|
627
|
-
* A clean resolve is a delivery statement (ADR-018). When the
|
|
628
|
-
* drain could not complete, shutdown rejects with classified
|
|
629
|
-
* `SHUTDOWN_TIMEOUT` and `dropped: { count }` — the counter's exact
|
|
630
|
-
* value — after the close has already happened (teardown first,
|
|
631
|
-
* then the report). This fires CONNECTED OR NOT: with no disk
|
|
632
|
-
* store, nothing survives the process, so a disconnected shutdown
|
|
633
|
-
* with pending messages is a real loss and is reported as one. (The
|
|
634
|
-
* wal-backed design resolved that case clean on purpose — its disk
|
|
635
|
-
* store held the messages for the next session. ADR-021 traded that
|
|
636
|
-
* away and says so.)
|
|
637
|
-
*/
|
|
638
|
-
const doShutdown = async function ( timeout ) {
|
|
639
|
-
state.shuttingDown = true;
|
|
640
|
-
|
|
641
|
-
// A non-finite or non-positive timeout collapses the drain
|
|
642
|
-
// (Infinity overflows setTimeout, which clamps it to ~1 ms;
|
|
643
|
-
// NaN fails every deadline comparison), so it falls back to
|
|
644
|
-
// the default budget.
|
|
645
|
-
const budget = ( Number.isFinite( timeout ) && timeout > 0 ) ? timeout : DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
646
|
-
const deadline = Date.now() + budget;
|
|
647
|
-
const drainPollIntervalMs = 25;
|
|
648
|
-
|
|
649
|
-
// Drain wait — one cheap counter read per tick; sequential
|
|
650
|
-
// awaits are the wait-for-condition pattern.
|
|
651
|
-
while ( Date.now() < deadline && state.unacked > 0 ) {
|
|
652
|
-
// eslint-disable-next-line no-await-in-loop
|
|
653
|
-
await new Promise( ( r ) => setTimeout( r, drainPollIntervalMs ) );
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Whatever is still unacknowledged goes undelivered — publishNow
|
|
657
|
-
// stopped accepting work at step 1, so the counter has settled
|
|
658
|
-
// into its final value. It becomes the `dropped` payload below.
|
|
659
|
-
const undeliveredCount = state.unacked;
|
|
660
|
-
const remaining = Date.now() - deadline;
|
|
661
|
-
const closeBudget = remaining < 0 ? Math.abs( remaining ) : 1;
|
|
662
|
-
|
|
663
|
-
await new Promise( ( resolve ) => {
|
|
664
|
-
const forceTimer = setTimeout( () => {
|
|
665
|
-
client.end( true );
|
|
666
|
-
resolve();
|
|
667
|
-
}, closeBudget );
|
|
668
|
-
|
|
669
|
-
if ( state.connected ) {
|
|
670
|
-
client.end( false, {}, () => {
|
|
671
|
-
clearTimeout( forceTimer );
|
|
672
|
-
resolve();
|
|
673
|
-
} );
|
|
674
|
-
} else {
|
|
675
|
-
clearTimeout( forceTimer );
|
|
676
|
-
client.end( true );
|
|
677
|
-
resolve();
|
|
678
|
-
}
|
|
679
|
-
} );
|
|
680
|
-
|
|
681
|
-
// Teardown first, then the report: the close has already happened;
|
|
682
|
-
// an incomplete drain must not read as a clean one (ADR-018).
|
|
683
|
-
if ( undeliveredCount > 0 ) {
|
|
684
|
-
const err = new Error(
|
|
685
|
-
`winkComposer/mqttEmitter: shutdown closed with ${undeliveredCount} message(s) unacknowledged`
|
|
686
|
-
);
|
|
687
|
-
err.code = 'SHUTDOWN_TIMEOUT';
|
|
688
|
-
err.dropped = { count: undeliveredCount };
|
|
689
|
-
throw err;
|
|
690
|
-
}
|
|
691
|
-
return undefined;
|
|
692
|
-
}; // doShutdown()
|
|
209
|
+
const client = openClient( resolved );
|
|
693
210
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
* `shutdown()` with no argument, `shutdown( {} )`, or
|
|
700
|
-
* `shutdown( { timeout: N } )`.
|
|
701
|
-
*
|
|
702
|
-
* The outcome is latched: the first call runs the shutdown, every
|
|
703
|
-
* later call returns the same promise. A caller
|
|
704
|
-
* arriving mid-drain waits for the real teardown; a caller arriving
|
|
705
|
-
* after a lossy shutdown sees the same classified rejection, never
|
|
706
|
-
* a contradicting clean resolve. One consequence: the first
|
|
707
|
-
* caller's `{ timeout }` governs; a later caller's is ignored.
|
|
708
|
-
*
|
|
709
|
-
* @param {{timeout?: number}} [options]
|
|
710
|
-
* @returns {Promise<void>}
|
|
711
|
-
*/
|
|
712
|
-
const shutdown = function ( { timeout = DEFAULT_SHUTDOWN_TIMEOUT_MS } = {} ) {
|
|
713
|
-
if ( !shutdownPromise ) {
|
|
714
|
-
shutdownPromise = doShutdown( timeout );
|
|
715
|
-
}
|
|
716
|
-
return shutdownPromise;
|
|
717
|
-
}; // shutdown()
|
|
718
|
-
|
|
719
|
-
// Connection handlers - minimal, no automatic status
|
|
720
|
-
client.on( 'connect', () => {
|
|
721
|
-
state.connected = true;
|
|
722
|
-
// mqtt.js fires 'connect' on every connack, including mid-run
|
|
723
|
-
// reconnects. Counting the later ones gives operators — and the
|
|
724
|
-
// release-soak signature policy — an observable "a reconnect
|
|
725
|
-
// happened during this run" fact via getHealth().stats.
|
|
726
|
-
if ( state.hasConnectedOnce ) {
|
|
727
|
-
state.stats.reconnects += 1;
|
|
728
|
-
}
|
|
729
|
-
state.hasConnectedOnce = true;
|
|
730
|
-
if ( config.debug ) {
|
|
731
|
-
logger.info( `winkComposer/mqttEmitter: Connected to ${redactedBrokerUrl}` );
|
|
732
|
-
}
|
|
211
|
+
const { getPressure, checkCritical, checkBackpressure, getHealth } = createHealth( {
|
|
212
|
+
state,
|
|
213
|
+
maxQueueSize: resolved.maxQueueSize,
|
|
214
|
+
onCritical: resolved.onCritical,
|
|
215
|
+
onBackpressure: resolved.onBackpressure
|
|
733
216
|
} );
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
state
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
217
|
+
const publishNow = createPublishNow( {
|
|
218
|
+
client,
|
|
219
|
+
state,
|
|
220
|
+
codec: resolved.codec,
|
|
221
|
+
getPressure,
|
|
222
|
+
checkCritical,
|
|
223
|
+
checkBackpressure,
|
|
224
|
+
onDeliveryFailure: resolved.onDeliveryFailure
|
|
740
225
|
} );
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
226
|
+
const { flush, shutdown } = createShutdown( { client, state } );
|
|
227
|
+
|
|
228
|
+
// The permanent handlers attach before the grace wait's one-shot
|
|
229
|
+
// listener, so a handle resolved via connack already reports
|
|
230
|
+
// `connected: true`.
|
|
231
|
+
attachConnectionHandlers( {
|
|
232
|
+
client,
|
|
233
|
+
state,
|
|
234
|
+
debug: resolved.debug,
|
|
235
|
+
redactedBrokerUrl: resolved.redactedBrokerUrl
|
|
747
236
|
} );
|
|
748
237
|
|
|
749
|
-
/**
|
|
750
|
-
* Health snapshot for operator monitoring (uniform across sinks).
|
|
751
|
-
*
|
|
752
|
-
* Returns the ADR-018 health floor `{status, connected, pressure}` plus
|
|
753
|
-
* this adapter's own `stats` addition. `stats.unacked` is the live
|
|
754
|
-
* counter — the number of messages
|
|
755
|
-
* accepted but not yet acknowledged by the broker; exactly what a
|
|
756
|
-
* process crash at this instant would cost (ADR-021).
|
|
757
|
-
*
|
|
758
|
-
* Status derivation (uniform with Terminal/QuestDB; see file header):
|
|
759
|
-
* - `red` when `!connected`
|
|
760
|
-
* - `yellow` when connected AND `pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD`
|
|
761
|
-
* - `green` otherwise
|
|
762
|
-
*
|
|
763
|
-
* The wal-backed design's `storeHealth`, `circuitState`, and `metrics`
|
|
764
|
-
* diagnostics died with the LevelDB store (ADR-021).
|
|
765
|
-
*
|
|
766
|
-
* @returns {Object} Health snapshot — required floor + live counter
|
|
767
|
-
*/
|
|
768
|
-
const getHealth = function () {
|
|
769
|
-
const pressure = getPressure();
|
|
770
|
-
|
|
771
|
-
let status;
|
|
772
|
-
if ( !state.connected ) {
|
|
773
|
-
status = 'red';
|
|
774
|
-
} else if ( pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
|
|
775
|
-
status = 'yellow';
|
|
776
|
-
} else {
|
|
777
|
-
status = 'green';
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
return {
|
|
781
|
-
// Required floor (ADR-018)
|
|
782
|
-
status,
|
|
783
|
-
connected: state.connected,
|
|
784
|
-
pressure,
|
|
785
|
-
// This adapter's addition beyond the floor
|
|
786
|
-
stats: { ...state.stats, unacked: state.unacked }
|
|
787
|
-
};
|
|
788
|
-
};
|
|
789
|
-
|
|
790
238
|
const handle = {
|
|
791
239
|
publishNow,
|
|
240
|
+
flush,
|
|
792
241
|
getHealth,
|
|
793
242
|
shutdown,
|
|
794
243
|
getPressure
|
|
795
244
|
};
|
|
796
245
|
|
|
797
|
-
// First-connack grace (bounded).
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
// attached before the wait's one-shot listener, so a handle
|
|
805
|
-
// resolved via connack already reports `connected: true`.
|
|
806
|
-
if ( connectGraceMs === 0 ) {
|
|
807
|
-
return handle;
|
|
246
|
+
// First-connack grace (bounded). By the time wire() hands the
|
|
247
|
+
// emitter to the flow, the client has either seen its first
|
|
248
|
+
// connack or spent the budget. Expiry is not an error — the
|
|
249
|
+
// posture stays 'recovering'. With connectGraceMs 0 no wait is
|
|
250
|
+
// armed and the promise resolves at once.
|
|
251
|
+
if ( resolved.connectGraceMs > 0 ) {
|
|
252
|
+
await waitForFirstConnect( client, resolved.connectGraceMs );
|
|
808
253
|
}
|
|
809
|
-
return
|
|
810
|
-
}; //
|
|
254
|
+
return handle;
|
|
255
|
+
}; // createEmitter()
|