@winkjs/composer 0.6.0 → 0.7.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 +1 -1
- 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 +99 -13
- 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 +574 -847
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +403 -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
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
* - Two unavoidable residuals, both bounded: (1) `Set.add`/`Set.delete`
|
|
29
29
|
* maintain V8's internal hash table — amortized O(1), memory bounded
|
|
30
30
|
* by `maxEntries`, and the identical profile the pre-ADR-022 cache
|
|
31
|
-
* already had (JS offers no pre-sized Set). (2)
|
|
32
|
-
*
|
|
31
|
+
* already had (JS offers no pre-sized Set). (2) The clock returns a
|
|
32
|
+
* fractional number, so the timestamp is transiently boxed; it
|
|
33
33
|
* unboxes on store into the Float64Array and dies in the nursery.
|
|
34
34
|
* - Eviction clears the ring slot so an evicted id string is not
|
|
35
35
|
* retained past its window — that release is deliberate.
|
|
@@ -48,9 +48,12 @@
|
|
|
48
48
|
* -----------
|
|
49
49
|
* 1. A dedup id is unique per message and repeats only on a QoS-1
|
|
50
50
|
* retransmission (the emitter stamps UUIDs).
|
|
51
|
-
* 2. The clock
|
|
52
|
-
* clock
|
|
53
|
-
*
|
|
51
|
+
* 2. The clock counts up, in milliseconds. The default is the
|
|
52
|
+
* stopwatch clock (`performance`), which a wall-clock step cannot
|
|
53
|
+
* move. A forward step of the wall clock would otherwise expire
|
|
54
|
+
* every entry at once; a backward step would hold entries one
|
|
55
|
+
* step too long (ADR-018, long-running stability). An injected
|
|
56
|
+
* `nowFn` must count up too.
|
|
54
57
|
*
|
|
55
58
|
* LIMITATIONS
|
|
56
59
|
* -----------
|
|
@@ -62,9 +65,9 @@
|
|
|
62
65
|
* effective window is maxEntries ÷ rate (the full 120 s up to
|
|
63
66
|
* about 550 msg/s at defaults; ~6.5 s at 10 k msg/s). Stated in
|
|
64
67
|
* ADR-022, not hidden.
|
|
65
|
-
* 3. Memory, measured (
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
+
* 3. Memory, measured on the private benchmark harness (2026-07-09):
|
|
69
|
+
* 86 bytes per production-shaped id, ~6.7 MB worst case at the
|
|
70
|
+
* 65,536 cap including bounded V8 Set slack.
|
|
68
71
|
*
|
|
69
72
|
* @see ADR-022 - The full decision
|
|
70
73
|
*/
|
|
@@ -73,6 +76,7 @@ import {
|
|
|
73
76
|
DEFAULT_DEDUP_WINDOW_MS,
|
|
74
77
|
DEFAULT_DEDUP_MAX_ENTRIES
|
|
75
78
|
} from './constants.js';
|
|
79
|
+
import { monotonicNow } from '../../utils/clock/index.js';
|
|
76
80
|
|
|
77
81
|
// ============================================================================
|
|
78
82
|
// VALIDATION HELPER
|
|
@@ -108,8 +112,9 @@ const assertPositiveInteger = function ( value, name ) {
|
|
|
108
112
|
* expires once its age reaches this many milliseconds
|
|
109
113
|
* @param {number} [options.maxEntries=65536] - Count cap: the memory
|
|
110
114
|
* guarantee; the oldest entry is evicted when the ring is full
|
|
111
|
-
* @param {function} [options.nowFn=
|
|
112
|
-
*
|
|
115
|
+
* @param {function} [options.nowFn=monotonicNow] - Clock source, in
|
|
116
|
+
* milliseconds and counting up. Injection point for deterministic
|
|
117
|
+
* tests; production uses the stopwatch clock
|
|
113
118
|
* @returns {Object} Cache instance with isDuplicate, size, clear, has
|
|
114
119
|
*/
|
|
115
120
|
const createDedupCache = function ( options = {} ) {
|
|
@@ -122,7 +127,7 @@ const createDedupCache = function ( options = {} ) {
|
|
|
122
127
|
const {
|
|
123
128
|
windowMs = DEFAULT_DEDUP_WINDOW_MS,
|
|
124
129
|
maxEntries = DEFAULT_DEDUP_MAX_ENTRIES,
|
|
125
|
-
nowFn =
|
|
130
|
+
nowFn = monotonicNow
|
|
126
131
|
} = options;
|
|
127
132
|
|
|
128
133
|
assertPositiveInteger( windowMs, 'windowMs' );
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
* of `configSchema` — the wiring layer adds them to the config at start
|
|
32
32
|
* time, after DSL validation. A user-supplied `onMessage` would be
|
|
33
33
|
* silently overwritten; the unknown-key rejection turns that mistake
|
|
34
|
-
* into a fail-fast error instead.
|
|
34
|
+
* into a fail-fast error instead. `onMessage` is also outside the
|
|
35
|
+
* callback guard on purpose: the flow's dispatch guard owns it, so a
|
|
36
|
+
* pipeline throw is contained once, at that one chokepoint (ADR-027).
|
|
35
37
|
*
|
|
36
38
|
* ASSUMPTIONS
|
|
37
39
|
* -----------
|
|
@@ -49,6 +51,10 @@
|
|
|
49
51
|
* 2. Duplicate filtering is opt-in per message: only messages that
|
|
50
52
|
* carry `winkDedupId` are protected. Watch the `dedupBypassed`
|
|
51
53
|
* counter to spot publishers that are not stamping ids.
|
|
54
|
+
* 3. No payload size cap and no intake rate cap. The broker's own
|
|
55
|
+
* per-client limits are the first line against a flood; TCP
|
|
56
|
+
* back-pressure bounds the rest. A cap is ADR-026 work
|
|
57
|
+
* (proposed), together with TLS and authentication.
|
|
52
58
|
*
|
|
53
59
|
* Usage in flow:
|
|
54
60
|
* import mqtt from './core/source-manager/mqtt/index.js';
|
|
@@ -65,8 +71,24 @@
|
|
|
65
71
|
*/
|
|
66
72
|
|
|
67
73
|
import { validators } from '../../utils/validate/index.js';
|
|
74
|
+
import { classifyAddress } from '../../utils/address/index.js';
|
|
68
75
|
import { createMQTTSourceClient } from './client.js';
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Schema validator for `brokerUrl`: non-empty and never `localhost`
|
|
79
|
+
* (ADR-030). A value the URL grammar cannot read passes; the MQTT
|
|
80
|
+
* library reports its own error for it.
|
|
81
|
+
*
|
|
82
|
+
* @param {*} value - The configured value
|
|
83
|
+
* @returns {boolean} Whether the value is allowed
|
|
84
|
+
*/
|
|
85
|
+
const isAllowedBrokerUrl = function ( value ) {
|
|
86
|
+
if ( !validators.nonEmptyString( value ) ) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return classifyAddress( value, 'url' ).kind !== 'localhost';
|
|
90
|
+
}; // isAllowedBrokerUrl()
|
|
91
|
+
|
|
70
92
|
// ============================================================================
|
|
71
93
|
// SOURCE ADAPTER
|
|
72
94
|
// ============================================================================
|
|
@@ -83,7 +105,9 @@ const id = 'mqtt';
|
|
|
83
105
|
* Crash-survival class per ADR-018. For a source the value describes
|
|
84
106
|
* the INPUT it can recover after a disconnect: the persistent broker
|
|
85
107
|
* session holds subscribed QoS-1 messages across the gap and replays
|
|
86
|
-
* them on reconnect.
|
|
108
|
+
* them on reconnect. The class is earned only under a fixed
|
|
109
|
+
* `clientId` and a broker with persistence on. The auto-generated name
|
|
110
|
+
* changes on every start, so a restart under it claims no backlog.
|
|
87
111
|
*
|
|
88
112
|
* @type {string}
|
|
89
113
|
*/
|
|
@@ -97,7 +121,9 @@ const durabilityClass = 'broker-queue';
|
|
|
97
121
|
* @returns {boolean}
|
|
98
122
|
*/
|
|
99
123
|
const isTopicOrTopicArray = function ( value ) {
|
|
100
|
-
if ( typeof value === 'string' && value.length > 0 )
|
|
124
|
+
if ( typeof value === 'string' && value.length > 0 ) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
101
127
|
if ( Array.isArray( value ) && value.length > 0 ) {
|
|
102
128
|
return value.every( ( t ) => typeof t === 'string' && t.length > 0 );
|
|
103
129
|
}
|
|
@@ -163,8 +189,9 @@ const configSchema = {
|
|
|
163
189
|
brokerUrl: {
|
|
164
190
|
type: 'string',
|
|
165
191
|
required: true,
|
|
166
|
-
validator:
|
|
167
|
-
error: 'brokerUrl must be a non-empty
|
|
192
|
+
validator: isAllowedBrokerUrl,
|
|
193
|
+
error: 'brokerUrl must be a non-empty broker URL with a literal address or a name, never ' +
|
|
194
|
+
'localhost; e.g., mqtt://127.0.0.1:1883'
|
|
168
195
|
},
|
|
169
196
|
topics: {
|
|
170
197
|
required: true,
|
|
@@ -29,6 +29,25 @@
|
|
|
29
29
|
* later failed on still counts here. Failure visibility belongs
|
|
30
30
|
* to the flow's MESSAGE_HANDLER_FAILED reports (ADR-018), not to
|
|
31
31
|
* this counter.
|
|
32
|
+
* - The logger facade (ADR-028) — one line per health edge, and a
|
|
33
|
+
* bounded line per record fault. These lines print with or without
|
|
34
|
+
* an `onStatus` handler. Inside a flow the runtime's own status
|
|
35
|
+
* wrapper forwards only red payloads when the user gave no handler,
|
|
36
|
+
* so a yellow edge that reached only the payload channel used to
|
|
37
|
+
* vanish. The line is the guaranteed audience. The payload
|
|
38
|
+
* additionally reaches a listening handler.
|
|
39
|
+
*
|
|
40
|
+
* yellow edge → warn source degraded [CODE]: <detail>
|
|
41
|
+
* red edge → error source error [CODE]: <detail>
|
|
42
|
+
* back to green → warn source recovered [CODE]: cleared after N s
|
|
43
|
+
* forced stop → warn source stopped: <note>
|
|
44
|
+
*
|
|
45
|
+
* An edge is a change of status or code. A phase change inside the
|
|
46
|
+
* same status and code prints nothing, so a retry storm cannot fill
|
|
47
|
+
* the log. A clean stop prints nothing. The per-record lines
|
|
48
|
+
* (`decode failed [DECODE_ERROR]`, `transform failed
|
|
49
|
+
* [CALLBACK_FAILED]`) print the first two of an episode in full and
|
|
50
|
+
* then one summary a minute, the emitter's attempt-line shape.
|
|
32
51
|
*
|
|
33
52
|
* Health rules (evaluated on events and on tick):
|
|
34
53
|
* - RED — a subscribe failure (connected but deaf — no retry
|
|
@@ -44,6 +63,12 @@
|
|
|
44
63
|
* Precedence is the order above; the first rule that matches names
|
|
45
64
|
* the code.
|
|
46
65
|
*
|
|
66
|
+
* Clock: the default `nowFn` is the stopwatch clock (`performance`),
|
|
67
|
+
* which only counts up. The wall clock can step when NTP corrects a
|
|
68
|
+
* board after boot or after a long time without network. A forward
|
|
69
|
+
* step would fire the 30 s red early; a backward step would delay it
|
|
70
|
+
* (ADR-018, long-running stability). Tests inject their own clock.
|
|
71
|
+
*
|
|
47
72
|
* Hot-path allocation profile (ADR-018 zero-alloc — allocation only where
|
|
48
73
|
* unavoidable, each residual stated):
|
|
49
74
|
* - Counter bumps, ring writes, and the health evaluation are plain
|
|
@@ -54,20 +79,18 @@
|
|
|
54
79
|
* metrics snapshots (1 Hz). Snapshots are fresh objects because a
|
|
55
80
|
* consumer may retain them; mutating a shared one under the
|
|
56
81
|
* caller's feet is a footgun we refuse.
|
|
57
|
-
* - `nowFn()`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* payloads via a classified `console.error` line (the CSV source's
|
|
63
|
-
* fallback pattern); lifecycle payloads stay quiet.
|
|
82
|
+
* - `nowFn()` runs once per received message to feed the quiet-period
|
|
83
|
+
* clock. It returns a fractional number, so the value is transiently
|
|
84
|
+
* boxed — the same profile the dedup cache documents.
|
|
85
|
+
* - The facade lines are built only on an edge or a record fault,
|
|
86
|
+
* never on the success path.
|
|
64
87
|
*
|
|
65
88
|
* ASSUMPTIONS
|
|
66
89
|
* -----------
|
|
67
90
|
* 1. All reporter calls come from one event loop (mqtt.js event
|
|
68
91
|
* handlers plus one interval) — there is no locking.
|
|
69
|
-
* 2.
|
|
70
|
-
* subtraction.
|
|
92
|
+
* 2. An injected `nowFn` counts up, in milliseconds. The time rules
|
|
93
|
+
* use plain subtraction.
|
|
71
94
|
*
|
|
72
95
|
* LIMITATIONS
|
|
73
96
|
* -----------
|
|
@@ -79,9 +102,13 @@
|
|
|
79
102
|
* 2. Counters are plain JS numbers: exact to 2^53. At one million
|
|
80
103
|
* messages per second that is ~285 years of uptime — not the
|
|
81
104
|
* binding constraint.
|
|
105
|
+
* 3. The per-record line bound paces on the stopwatch clock too
|
|
106
|
+
* (the shared line-rate helper), so a wall-clock step moves
|
|
107
|
+
* neither a summary nor what the health rules decide.
|
|
82
108
|
*
|
|
83
109
|
* @see src/core/source-manager/mqtt/client.js - The transport wiring
|
|
84
110
|
* @see ADR-018 - lifecycle phases, status shapes, error codes
|
|
111
|
+
* @see ADR-028 - the line grammar
|
|
85
112
|
*/
|
|
86
113
|
|
|
87
114
|
import {
|
|
@@ -89,8 +116,16 @@ import {
|
|
|
89
116
|
DECODE_RING_SIZE
|
|
90
117
|
} from './constants.js';
|
|
91
118
|
import { wrapCallback } from '../../utils/callback-guard/index.js';
|
|
119
|
+
import { createLineBound } from '../../utils/line-rate/index.js';
|
|
120
|
+
import { monotonicNow } from '../../utils/clock/index.js';
|
|
92
121
|
import { logger } from '../../logger/index.js';
|
|
93
122
|
|
|
123
|
+
/** How many per-record fault lines per episode print in full. */
|
|
124
|
+
const FULL_RECORD_LINES_PER_EPISODE = 2;
|
|
125
|
+
|
|
126
|
+
/** The per-record summary interval, also the quiet gap that ends an episode. */
|
|
127
|
+
const RECORD_SUMMARY_INTERVAL_MS = 60000;
|
|
128
|
+
|
|
94
129
|
// ============================================================================
|
|
95
130
|
// VALIDATION HELPERS
|
|
96
131
|
// ============================================================================
|
|
@@ -110,6 +145,31 @@ const assertOptionalFunction = function ( value, name ) {
|
|
|
110
145
|
}
|
|
111
146
|
};
|
|
112
147
|
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// FACADE LINES
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Builds the bounded facade line for one per-record fault family. The
|
|
154
|
+
* first FULL_RECORD_LINES_PER_EPISODE faults of an episode print in
|
|
155
|
+
* full; later ones are counted into one summary a minute.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} label - The line's message, e.g. `decode failed [DECODE_ERROR]`
|
|
158
|
+
* @returns {Function} `( detail ) => void`
|
|
159
|
+
*/
|
|
160
|
+
const createRecordLine = function ( label ) {
|
|
161
|
+
return createLineBound( {
|
|
162
|
+
fullLines: FULL_RECORD_LINES_PER_EPISODE,
|
|
163
|
+
intervalMs: RECORD_SUMMARY_INTERVAL_MS,
|
|
164
|
+
printFull: function ( detail ) {
|
|
165
|
+
logger.warn( `winkComposer/mqttSource: ${label}: ${detail}` );
|
|
166
|
+
},
|
|
167
|
+
printSummary: function ( count, seconds, detail ) {
|
|
168
|
+
logger.warn( `winkComposer/mqttSource: ${label}: ${detail}; ${count} more in the last ${seconds} s` );
|
|
169
|
+
}
|
|
170
|
+
} );
|
|
171
|
+
}; // createRecordLine()
|
|
172
|
+
|
|
113
173
|
// ============================================================================
|
|
114
174
|
// STATUS REPORTER FACTORY
|
|
115
175
|
// ============================================================================
|
|
@@ -123,8 +183,9 @@ const assertOptionalFunction = function ( value, name ) {
|
|
|
123
183
|
* @param {number} [options.expectedQuietPeriodMs] - Opt-in quiet rule:
|
|
124
184
|
* yellow when no packet arrives for longer than this
|
|
125
185
|
* @param {function} [options.dedupSizeFn] - Live dedup-cache size read
|
|
126
|
-
* @param {function} [options.nowFn=
|
|
127
|
-
*
|
|
186
|
+
* @param {function} [options.nowFn=monotonicNow] - Clock source, in
|
|
187
|
+
* milliseconds and counting up. Injection point for deterministic
|
|
188
|
+
* tests; production uses the stopwatch clock
|
|
128
189
|
* @returns {Object} Reporter with lifecycle notes, hot-path counters,
|
|
129
190
|
* tick and snapshot
|
|
130
191
|
*/
|
|
@@ -140,7 +201,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
140
201
|
onMetrics = null,
|
|
141
202
|
expectedQuietPeriodMs = null,
|
|
142
203
|
dedupSizeFn = null,
|
|
143
|
-
nowFn =
|
|
204
|
+
nowFn = monotonicNow
|
|
144
205
|
} = options;
|
|
145
206
|
|
|
146
207
|
assertOptionalFunction( onStatus, 'onStatus' );
|
|
@@ -192,6 +253,18 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
192
253
|
let lastPhase = null;
|
|
193
254
|
let lastCode = null;
|
|
194
255
|
|
|
256
|
+
// ── Facade-line state (ADR-028) ──────────────────────────────────
|
|
257
|
+
// The edge lines key on status and code only, so a phase change
|
|
258
|
+
// inside one degraded episode prints nothing. `degradedSince` and
|
|
259
|
+
// `degradedPhase` remember the first non-green edge of the
|
|
260
|
+
// episode for the recovery line.
|
|
261
|
+
let printedStatus = 'green';
|
|
262
|
+
let printedCode = null;
|
|
263
|
+
let degradedSince = 0;
|
|
264
|
+
let degradedPhase = null;
|
|
265
|
+
const decodeLine = createRecordLine( 'decode failed [DECODE_ERROR]' );
|
|
266
|
+
const transformLine = createRecordLine( 'transform failed [CALLBACK_FAILED]' );
|
|
267
|
+
|
|
195
268
|
/**
|
|
196
269
|
* Record one decode outcome in the ring.
|
|
197
270
|
*
|
|
@@ -229,9 +302,9 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
229
302
|
// Both user callbacks are armed by the shared callback guard.
|
|
230
303
|
// They were validated raw above and are wrapped here, once
|
|
231
304
|
// (ADR-018: a misbehaving user callback never reaches transport
|
|
232
|
-
// code and never fails silently). A broken onStatus reports
|
|
233
|
-
// the
|
|
234
|
-
// cannot report through itself. A broken onMetrics reports on
|
|
305
|
+
// code and never fails silently). A broken onStatus reports
|
|
306
|
+
// through the facade in this adapter's classified line family.
|
|
307
|
+
// It cannot report through itself. A broken onMetrics reports on
|
|
235
308
|
// both channels; the comment at its wrap below explains why.
|
|
236
309
|
const safeOnStatus = wrapCallback( onStatus, {
|
|
237
310
|
name: 'onStatus',
|
|
@@ -242,18 +315,15 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
242
315
|
} );
|
|
243
316
|
|
|
244
317
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* payloads stay quiet.
|
|
318
|
+
* Hand a status payload to the caller's handler, when there is
|
|
319
|
+
* one. The facade lines are printed by the callers of this
|
|
320
|
+
* function, so a payload with no handler is not silent.
|
|
249
321
|
*
|
|
250
322
|
* @param {Object} payload - The structured status payload
|
|
251
323
|
*/
|
|
252
324
|
const emitStatus = function ( payload ) {
|
|
253
325
|
if ( safeOnStatus ) {
|
|
254
326
|
safeOnStatus( payload );
|
|
255
|
-
} else if ( payload.error ) {
|
|
256
|
-
logger.error( `winkComposer/mqttSource: source error [${payload.error.code}]: ${payload.error.message}` );
|
|
257
327
|
}
|
|
258
328
|
};
|
|
259
329
|
|
|
@@ -261,31 +331,27 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
261
331
|
// `connected` and `phase` read the reporter's live state at fault
|
|
262
332
|
// time, so the payload tells the truth about the moment it fired.
|
|
263
333
|
//
|
|
264
|
-
// The
|
|
265
|
-
// guaranteed audience
|
|
266
|
-
// onStatus wrapper
|
|
267
|
-
// user gave no handler, so the yellow payload alone could
|
|
268
|
-
// (fresh-eyes find, 2026-08-28). The payload then
|
|
269
|
-
// reaches a listening handler, so a health view sees
|
|
270
|
-
// degradation.
|
|
271
|
-
// the same line a second time — skip the payload there.
|
|
334
|
+
// The facade line comes first and always fires. It is the
|
|
335
|
+
// guaranteed audience. Inside a flow the runtime installs its own
|
|
336
|
+
// onStatus wrapper. That wrapper forwards only red payloads when
|
|
337
|
+
// the user gave no handler, so the yellow payload alone could
|
|
338
|
+
// vanish (fresh-eyes find, 2026-08-28). The payload then
|
|
339
|
+
// additionally reaches a listening handler, so a health view sees
|
|
340
|
+
// the degradation.
|
|
272
341
|
const safeOnMetrics = wrapCallback( onMetrics, {
|
|
273
342
|
name: 'onMetrics',
|
|
274
343
|
severity: 'yellow',
|
|
275
344
|
report: function ( severity, name, detail ) {
|
|
276
|
-
const message = `user callback ${name} failed: ${detail}`;
|
|
277
345
|
logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
} );
|
|
288
|
-
}
|
|
346
|
+
emitStatus( {
|
|
347
|
+
status: severity,
|
|
348
|
+
connected,
|
|
349
|
+
phase,
|
|
350
|
+
error: {
|
|
351
|
+
code: 'CALLBACK_FAILED',
|
|
352
|
+
message: `user callback ${name} failed: ${detail}`
|
|
353
|
+
}
|
|
354
|
+
} );
|
|
289
355
|
}
|
|
290
356
|
} );
|
|
291
357
|
|
|
@@ -352,7 +418,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
352
418
|
return lastSubscribeError;
|
|
353
419
|
}
|
|
354
420
|
if ( code === 'CONNECTION_LOST' ) {
|
|
355
|
-
return `not connected for ${now - disconnectedAt}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
|
|
421
|
+
return `not connected for ${Math.round( now - disconnectedAt )}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
|
|
356
422
|
}
|
|
357
423
|
if ( code === 'CONNECT_FAILED' ) {
|
|
358
424
|
return lastConnectError;
|
|
@@ -361,7 +427,43 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
361
427
|
return `decode-error ratio above 1% over the last ${ringCount} messages`;
|
|
362
428
|
}
|
|
363
429
|
// QUIET_PERIOD_EXCEEDED — the only code left.
|
|
364
|
-
return `no message received for ${now - lastReceivedAt}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
|
|
430
|
+
return `no message received for ${Math.round( now - lastReceivedAt )}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Print one facade line per health edge (ADR-028). An edge is a
|
|
435
|
+
* change of status or code. A yellow edge prints at warn, a red
|
|
436
|
+
* edge at error. The return to green prints at warn with the
|
|
437
|
+
* seconds since the first non-green edge of the episode. A stop
|
|
438
|
+
* is not a recovery, so it prints nothing.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} status - The status just computed
|
|
441
|
+
* @param {number} now - Current timestamp
|
|
442
|
+
*/
|
|
443
|
+
const printEdge = function ( status, now ) {
|
|
444
|
+
if ( status === printedStatus && healthCode === printedCode ) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if ( status === 'green' ) {
|
|
448
|
+
if ( phase !== 'stopped' ) {
|
|
449
|
+
const seconds = Math.round( ( now - degradedSince ) / 1000 );
|
|
450
|
+
const what = printedCode === null ? `: ${degradedPhase} cleared` : ` [${printedCode}]: cleared`;
|
|
451
|
+
logger.warn( `winkComposer/mqttSource: source recovered${what} after ${seconds} s` );
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
if ( printedStatus === 'green' ) {
|
|
455
|
+
degradedSince = now;
|
|
456
|
+
degradedPhase = phase;
|
|
457
|
+
}
|
|
458
|
+
const what = healthCode === null ? `: ${phase}` : ` [${healthCode}]: ${healthMessage( healthCode, now )}`;
|
|
459
|
+
if ( status === 'red' ) {
|
|
460
|
+
logger.error( `winkComposer/mqttSource: source error${what}` );
|
|
461
|
+
} else {
|
|
462
|
+
logger.warn( `winkComposer/mqttSource: source degraded${what}` );
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
printedStatus = status;
|
|
466
|
+
printedCode = healthCode;
|
|
365
467
|
};
|
|
366
468
|
|
|
367
469
|
/**
|
|
@@ -379,11 +481,12 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
379
481
|
lastPhase = phase;
|
|
380
482
|
lastCode = healthCode;
|
|
381
483
|
|
|
484
|
+
printEdge( status, now );
|
|
382
485
|
const payload = {
|
|
383
486
|
status,
|
|
384
487
|
connected,
|
|
385
488
|
phase,
|
|
386
|
-
msSinceLastMsg: now - lastReceivedAt
|
|
489
|
+
msSinceLastMsg: Math.round( now - lastReceivedAt )
|
|
387
490
|
};
|
|
388
491
|
if ( healthCode !== null ) {
|
|
389
492
|
payload.error = { code: healthCode, message: healthMessage( healthCode, now ) };
|
|
@@ -476,7 +579,8 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
476
579
|
/**
|
|
477
580
|
* The stop's time budget ran out and the socket was forced
|
|
478
581
|
* closed. Reported with the sources' shared `note` convention
|
|
479
|
-
* (CSV and testHarness use the same wording)
|
|
582
|
+
* (CSV and testHarness use the same wording), and as one warn
|
|
583
|
+
* line.
|
|
480
584
|
*
|
|
481
585
|
* @param {number} timeoutMs - The exceeded budget
|
|
482
586
|
*/
|
|
@@ -484,15 +588,19 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
484
588
|
phase = 'stopped';
|
|
485
589
|
connected = false;
|
|
486
590
|
const now = nowFn();
|
|
591
|
+
const note = `Stop took longer than ${timeoutMs}ms — forced.`;
|
|
487
592
|
lastStatus = 'yellow';
|
|
488
593
|
lastPhase = 'stopped';
|
|
489
594
|
lastCode = null;
|
|
595
|
+
printedStatus = 'yellow';
|
|
596
|
+
printedCode = null;
|
|
597
|
+
logger.warn( `winkComposer/mqttSource: source stopped: ${note}` );
|
|
490
598
|
emitStatus( {
|
|
491
599
|
status: 'yellow',
|
|
492
600
|
connected: false,
|
|
493
601
|
phase: 'stopped',
|
|
494
|
-
msSinceLastMsg: now - lastReceivedAt,
|
|
495
|
-
note
|
|
602
|
+
msSinceLastMsg: Math.round( now - lastReceivedAt ),
|
|
603
|
+
note
|
|
496
604
|
} );
|
|
497
605
|
emitMetrics();
|
|
498
606
|
},
|
|
@@ -527,9 +635,10 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
527
635
|
},
|
|
528
636
|
|
|
529
637
|
/**
|
|
530
|
-
* A payload could not be decoded.
|
|
531
|
-
* per-record report (ADR-018: skip,
|
|
532
|
-
* silent), then re-evaluates the
|
|
638
|
+
* A payload could not be decoded. Prints the bounded facade
|
|
639
|
+
* line, emits the mandated per-record report (ADR-018: skip,
|
|
640
|
+
* classify, continue — never silent), then re-evaluates the
|
|
641
|
+
* ratio rule.
|
|
533
642
|
*
|
|
534
643
|
* @param {string} detail - Operator-facing description
|
|
535
644
|
*/
|
|
@@ -538,6 +647,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
538
647
|
skipped += 1;
|
|
539
648
|
lastReceivedAt = nowFn();
|
|
540
649
|
ringPush( 1 );
|
|
650
|
+
decodeLine( detail );
|
|
541
651
|
emitStatus( {
|
|
542
652
|
status: 'yellow',
|
|
543
653
|
connected,
|
|
@@ -555,17 +665,19 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
555
665
|
},
|
|
556
666
|
|
|
557
667
|
/**
|
|
558
|
-
* The user's transform threw.
|
|
559
|
-
* report (skip, classify,
|
|
560
|
-
* source-transform-
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* there is no transition to
|
|
668
|
+
* The user's transform threw. Prints the bounded facade line
|
|
669
|
+
* and emits the mandated per-record report (skip, classify,
|
|
670
|
+
* continue — the transform contract, source-transform-
|
|
671
|
+
* uniformity 2026-07-11), classified as CALLBACK_FAILED: user
|
|
672
|
+
* code, never a transport failure. No evaluate() — a transform
|
|
673
|
+
* throw feeds no health rule, so there is no transition to
|
|
674
|
+
* re-derive.
|
|
564
675
|
*
|
|
565
676
|
* @param {string} detail - Operator-facing description
|
|
566
677
|
*/
|
|
567
678
|
transformFailed: function ( detail ) {
|
|
568
679
|
skipped += 1;
|
|
680
|
+
transformLine( detail );
|
|
569
681
|
emitStatus( {
|
|
570
682
|
status: 'yellow',
|
|
571
683
|
connected,
|