@winkjs/composer 0.5.1 → 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 +3 -3
- package/src/composer.js +1 -1
- package/src/core/batch/aoo-encoder.js +4 -4
- package/src/core/batch/sliding-window.js +2 -2
- package/src/core/codec/index.js +4 -4
- 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 +108 -633
- 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/assert-columns.js +1 -1
- package/src/core/emitter-manager/terminal/index.js +20 -5
- package/src/core/env-vars.js +143 -16
- package/src/core/logger/index.js +174 -0
- package/src/core/mqtt-protocol.js +2 -2
- package/src/core/partition-manager/get-stats.js +31 -0
- package/src/core/partition-manager/index.js +1 -0
- package/src/core/partition-manager/init.js +15 -0
- package/src/core/partition-manager/update.js +143 -87
- package/src/core/partition-manager/validate-flow.js +1 -1
- package/src/core/semantics/loader.js +1 -1
- package/src/core/semantics/warnings.js +5 -3
- package/src/core/shutdown-manager/index.js +71 -21
- package/src/core/source-manager/csv/start.js +109 -35
- package/src/core/source-manager/mqtt/client.js +276 -83
- package/src/core/source-manager/mqtt/dedup.js +19 -14
- package/src/core/source-manager/mqtt/index.js +33 -6
- package/src/core/source-manager/mqtt/status.js +211 -46
- package/src/core/source-manager/record-shape.js +55 -0
- package/src/core/source-manager/test-harness/field-generator.js +1 -1
- package/src/core/source-manager/test-harness/start.js +52 -10
- package/src/core/source-manager/test-harness/validate.js +1 -1
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/assert-columns.js +2 -2
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
- 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 +592 -827
- package/src/core/storage-manager/questdb/persist-plan.js +147 -162
- 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/tunable/helpers.js +1 -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 +176 -0
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/half-life/index.js +4 -4
- 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/utils/validate/composer-validators.js +2 -2
- package/src/core/utils/validate/index.js +1 -1
- package/src/core/utils/validate/validate.js +1 -1
- package/src/core/wiring/assert-handle.js +5 -5
- package/src/core/wiring/assert-module.js +2 -2
- package/src/core/wiring/wire-emitters.js +7 -4
- package/src/core/wiring/wire-linear-graph.js +1 -1
- package/src/core/wiring/wire-node.js +6 -6
- package/src/core/wiring/wire-semantics.js +1 -1
- package/src/core/wiring/wire-storages.js +14 -5
- package/src/flow/driver.js +31 -6
- package/src/flow/expand-single-input-array.js +4 -4
- package/src/flow/flow-api-schemas.js +4 -4
- package/src/flow/flow.js +62 -41
- package/src/flow/for-each.js +12 -11
- package/src/flow/get-target-array.js +2 -2
- package/src/flow/load-node-module.js +1 -1
- package/src/flow/make-node-method.js +4 -4
- package/src/flow/resolve-pick-by-field.js +1 -1
- package/src/flow/run.js +200 -40
- package/src/flow/serialize.js +1 -1
- package/src/flow/spec-builder.js +1 -1
- package/src/flow/validate.js +1 -1
- package/src/nodes/butterworth-filter/init.js +5 -4
- package/src/nodes/categorize/update.js +3 -1
- package/src/nodes/controller/update.js +2 -1
- package/src/nodes/dwell-time-tracker/update.js +2 -1
- package/src/nodes/emit-if/helpers.js +57 -11
- package/src/nodes/emit-if/init.js +1 -1
- package/src/nodes/emit-if/update.js +8 -11
- package/src/nodes/page-hinkley/update.js +2 -1
- package/src/nodes/pass-if/update.js +3 -1
- package/src/nodes/persist-if/helpers.js +60 -4
- package/src/nodes/persist-if/init.js +1 -1
- package/src/nodes/persist-if/update.js +8 -16
- package/src/nodes/persistence-check/update.js +2 -1
- package/src/nodes/sanitize/update.js +4 -2
- package/src/nodes/swing-watch/update.js +3 -2
- package/src/nodes/threshold/update.js +2 -1
- package/src/nodes/trend/compute-confidence.js +4 -4
- package/src/nodes/trend/update.js +2 -1
- package/src/nodes/winnow/update.js +3 -1
- package/src/tools/training/features.js +13 -13
- package/src/tools/training/logistic.js +1 -1
- package/src/tools/training/metrics.js +2 -2
- package/src/tools/training/scale.js +2 -2
- package/src/tools/training/validate.js +7 -7
- package/src/core/utils/console-formatter/index.js +0 -207
- package/src/nodes/archive/csv/index.js +0 -6
- package/src/nodes/archive/csv/init.js +0 -36
- package/src/nodes/archive/csv/load.js +0 -70
- package/src/nodes/archive/csv/reset.js +0 -14
- package/src/nodes/archive/csv/simple-parser.js +0 -216
- package/src/nodes/archive/csv/test.js +0 -173
- package/src/nodes/archive/csv/update.js +0 -63
- package/src/nodes/sse-emitter/index.js +0 -7
- package/src/nodes/sse-emitter/init.js +0 -105
- package/src/nodes/sse-emitter/introspect.js +0 -134
- package/src/nodes/sse-emitter/publish-to.js +0 -6
- package/src/nodes/sse-emitter/recompute.js +0 -16
- package/src/nodes/sse-emitter/reset.js +0 -19
- package/src/nodes/sse-emitter/update.js +0 -69
- package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
- package/src/nodes/sse-emitter/utils/build-message.js +0 -31
- package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
- package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
- package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
- package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// core/source-manager/mqtt/index.js
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @fileoverview MQTT source adapter for
|
|
4
|
+
* @fileoverview MQTT source adapter for winkComposer.
|
|
5
5
|
*
|
|
6
6
|
* Subscribes to edge composer MQTT outputs with deduplication for QoS 1.
|
|
7
7
|
* Extracts winkDedupId from MQTT v5 user properties to filter retransmissions.
|
|
@@ -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,
|
|
@@ -25,6 +25,29 @@
|
|
|
25
25
|
* dedupHits, dedupMisses, dedupBypassed, dedupCacheSize}`, emitted
|
|
26
26
|
* on every tick() (the client's 1 Hz cadence) and on every health
|
|
27
27
|
* transition. Consumers diff snapshots; nothing is ever reset.
|
|
28
|
+
* `delivered` means handed to the flow: a message the pipeline
|
|
29
|
+
* later failed on still counts here. Failure visibility belongs
|
|
30
|
+
* to the flow's MESSAGE_HANDLER_FAILED reports (ADR-018), not to
|
|
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.
|
|
28
51
|
*
|
|
29
52
|
* Health rules (evaluated on events and on tick):
|
|
30
53
|
* - RED — a subscribe failure (connected but deaf — no retry
|
|
@@ -40,6 +63,12 @@
|
|
|
40
63
|
* Precedence is the order above; the first rule that matches names
|
|
41
64
|
* the code.
|
|
42
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
|
+
*
|
|
43
72
|
* Hot-path allocation profile (ADR-018 zero-alloc — allocation only where
|
|
44
73
|
* unavoidable, each residual stated):
|
|
45
74
|
* - Counter bumps, ring writes, and the health evaluation are plain
|
|
@@ -50,20 +79,18 @@
|
|
|
50
79
|
* metrics snapshots (1 Hz). Snapshots are fresh objects because a
|
|
51
80
|
* consumer may retain them; mutating a shared one under the
|
|
52
81
|
* caller's feet is a footgun we refuse.
|
|
53
|
-
* - `nowFn()`
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* payloads via a classified `console.error` line (the CSV source's
|
|
59
|
-
* 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.
|
|
60
87
|
*
|
|
61
88
|
* ASSUMPTIONS
|
|
62
89
|
* -----------
|
|
63
90
|
* 1. All reporter calls come from one event loop (mqtt.js event
|
|
64
91
|
* handlers plus one interval) — there is no locking.
|
|
65
|
-
* 2.
|
|
66
|
-
* subtraction.
|
|
92
|
+
* 2. An injected `nowFn` counts up, in milliseconds. The time rules
|
|
93
|
+
* use plain subtraction.
|
|
67
94
|
*
|
|
68
95
|
* LIMITATIONS
|
|
69
96
|
* -----------
|
|
@@ -75,15 +102,29 @@
|
|
|
75
102
|
* 2. Counters are plain JS numbers: exact to 2^53. At one million
|
|
76
103
|
* messages per second that is ~285 years of uptime — not the
|
|
77
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.
|
|
78
108
|
*
|
|
79
109
|
* @see src/core/source-manager/mqtt/client.js - The transport wiring
|
|
80
110
|
* @see ADR-018 - lifecycle phases, status shapes, error codes
|
|
111
|
+
* @see ADR-028 - the line grammar
|
|
81
112
|
*/
|
|
82
113
|
|
|
83
114
|
import {
|
|
84
115
|
DISCONNECT_RED_MS,
|
|
85
116
|
DECODE_RING_SIZE
|
|
86
117
|
} from './constants.js';
|
|
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';
|
|
121
|
+
import { logger } from '../../logger/index.js';
|
|
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;
|
|
87
128
|
|
|
88
129
|
// ============================================================================
|
|
89
130
|
// VALIDATION HELPERS
|
|
@@ -98,12 +139,37 @@ import {
|
|
|
98
139
|
*/
|
|
99
140
|
const assertOptionalFunction = function ( value, name ) {
|
|
100
141
|
if ( value !== undefined && value !== null && typeof value !== 'function' ) {
|
|
101
|
-
const err = new Error( `
|
|
142
|
+
const err = new Error( `winkComposer/mqttSource: ${name} must be a function` );
|
|
102
143
|
err.code = 'INVALID_CONFIG';
|
|
103
144
|
throw err;
|
|
104
145
|
}
|
|
105
146
|
};
|
|
106
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
|
+
|
|
107
173
|
// ============================================================================
|
|
108
174
|
// STATUS REPORTER FACTORY
|
|
109
175
|
// ============================================================================
|
|
@@ -117,14 +183,15 @@ const assertOptionalFunction = function ( value, name ) {
|
|
|
117
183
|
* @param {number} [options.expectedQuietPeriodMs] - Opt-in quiet rule:
|
|
118
184
|
* yellow when no packet arrives for longer than this
|
|
119
185
|
* @param {function} [options.dedupSizeFn] - Live dedup-cache size read
|
|
120
|
-
* @param {function} [options.nowFn=
|
|
121
|
-
*
|
|
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
|
|
122
189
|
* @returns {Object} Reporter with lifecycle notes, hot-path counters,
|
|
123
190
|
* tick and snapshot
|
|
124
191
|
*/
|
|
125
192
|
const createStatusReporter = function ( options = {} ) {
|
|
126
193
|
if ( typeof options !== 'object' || options === null ) {
|
|
127
|
-
const err = new Error( '
|
|
194
|
+
const err = new Error( 'winkComposer/mqttSource: status reporter options must be an object' );
|
|
128
195
|
err.code = 'INVALID_CONFIG';
|
|
129
196
|
throw err;
|
|
130
197
|
}
|
|
@@ -134,14 +201,14 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
134
201
|
onMetrics = null,
|
|
135
202
|
expectedQuietPeriodMs = null,
|
|
136
203
|
dedupSizeFn = null,
|
|
137
|
-
nowFn =
|
|
204
|
+
nowFn = monotonicNow
|
|
138
205
|
} = options;
|
|
139
206
|
|
|
140
207
|
assertOptionalFunction( onStatus, 'onStatus' );
|
|
141
208
|
assertOptionalFunction( onMetrics, 'onMetrics' );
|
|
142
209
|
assertOptionalFunction( dedupSizeFn, 'dedupSizeFn' );
|
|
143
210
|
if ( typeof nowFn !== 'function' ) {
|
|
144
|
-
const err = new Error( '
|
|
211
|
+
const err = new Error( 'winkComposer/mqttSource: nowFn must be a function' );
|
|
145
212
|
err.code = 'INVALID_CONFIG';
|
|
146
213
|
throw err;
|
|
147
214
|
}
|
|
@@ -149,7 +216,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
149
216
|
( typeof expectedQuietPeriodMs !== 'number' ||
|
|
150
217
|
expectedQuietPeriodMs < 1 ||
|
|
151
218
|
!Number.isInteger( expectedQuietPeriodMs ) ) ) {
|
|
152
|
-
const err = new Error( '
|
|
219
|
+
const err = new Error( 'winkComposer/mqttSource: expectedQuietPeriodMs must be a positive integer' );
|
|
153
220
|
err.code = 'INVALID_CONFIG';
|
|
154
221
|
throw err;
|
|
155
222
|
}
|
|
@@ -186,6 +253,18 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
186
253
|
let lastPhase = null;
|
|
187
254
|
let lastCode = null;
|
|
188
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
|
+
|
|
189
268
|
/**
|
|
190
269
|
* Record one decode outcome in the ring.
|
|
191
270
|
*
|
|
@@ -220,28 +299,68 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
220
299
|
};
|
|
221
300
|
};
|
|
222
301
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
302
|
+
// Both user callbacks are armed by the shared callback guard.
|
|
303
|
+
// They were validated raw above and are wrapped here, once
|
|
304
|
+
// (ADR-018: a misbehaving user callback never reaches transport
|
|
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
|
|
308
|
+
// both channels; the comment at its wrap below explains why.
|
|
309
|
+
const safeOnStatus = wrapCallback( onStatus, {
|
|
310
|
+
name: 'onStatus',
|
|
311
|
+
severity: 'red',
|
|
312
|
+
report: function ( severity, name, detail ) {
|
|
313
|
+
logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
|
|
229
314
|
}
|
|
230
|
-
};
|
|
315
|
+
} );
|
|
231
316
|
|
|
232
317
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* 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.
|
|
237
321
|
*
|
|
238
322
|
* @param {Object} payload - The structured status payload
|
|
239
323
|
*/
|
|
240
324
|
const emitStatus = function ( payload ) {
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
325
|
+
if ( safeOnStatus ) {
|
|
326
|
+
safeOnStatus( payload );
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// The metrics fault report allocates on the failure path only.
|
|
331
|
+
// `connected` and `phase` read the reporter's live state at fault
|
|
332
|
+
// time, so the payload tells the truth about the moment it fired.
|
|
333
|
+
//
|
|
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.
|
|
341
|
+
const safeOnMetrics = wrapCallback( onMetrics, {
|
|
342
|
+
name: 'onMetrics',
|
|
343
|
+
severity: 'yellow',
|
|
344
|
+
report: function ( severity, name, detail ) {
|
|
345
|
+
logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
|
|
346
|
+
emitStatus( {
|
|
347
|
+
status: severity,
|
|
348
|
+
connected,
|
|
349
|
+
phase,
|
|
350
|
+
error: {
|
|
351
|
+
code: 'CALLBACK_FAILED',
|
|
352
|
+
message: `user callback ${name} failed: ${detail}`
|
|
353
|
+
}
|
|
354
|
+
} );
|
|
355
|
+
}
|
|
356
|
+
} );
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Emit a metrics snapshot when the caller asked for one.
|
|
360
|
+
*/
|
|
361
|
+
const emitMetrics = function () {
|
|
362
|
+
if ( safeOnMetrics ) {
|
|
363
|
+
safeOnMetrics( snapshot() );
|
|
245
364
|
}
|
|
246
365
|
};
|
|
247
366
|
|
|
@@ -299,7 +418,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
299
418
|
return lastSubscribeError;
|
|
300
419
|
}
|
|
301
420
|
if ( code === 'CONNECTION_LOST' ) {
|
|
302
|
-
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)`;
|
|
303
422
|
}
|
|
304
423
|
if ( code === 'CONNECT_FAILED' ) {
|
|
305
424
|
return lastConnectError;
|
|
@@ -308,7 +427,43 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
308
427
|
return `decode-error ratio above 1% over the last ${ringCount} messages`;
|
|
309
428
|
}
|
|
310
429
|
// QUIET_PERIOD_EXCEEDED — the only code left.
|
|
311
|
-
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;
|
|
312
467
|
};
|
|
313
468
|
|
|
314
469
|
/**
|
|
@@ -326,11 +481,12 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
326
481
|
lastPhase = phase;
|
|
327
482
|
lastCode = healthCode;
|
|
328
483
|
|
|
484
|
+
printEdge( status, now );
|
|
329
485
|
const payload = {
|
|
330
486
|
status,
|
|
331
487
|
connected,
|
|
332
488
|
phase,
|
|
333
|
-
msSinceLastMsg: now - lastReceivedAt
|
|
489
|
+
msSinceLastMsg: Math.round( now - lastReceivedAt )
|
|
334
490
|
};
|
|
335
491
|
if ( healthCode !== null ) {
|
|
336
492
|
payload.error = { code: healthCode, message: healthMessage( healthCode, now ) };
|
|
@@ -423,7 +579,8 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
423
579
|
/**
|
|
424
580
|
* The stop's time budget ran out and the socket was forced
|
|
425
581
|
* closed. Reported with the sources' shared `note` convention
|
|
426
|
-
* (CSV and testHarness use the same wording)
|
|
582
|
+
* (CSV and testHarness use the same wording), and as one warn
|
|
583
|
+
* line.
|
|
427
584
|
*
|
|
428
585
|
* @param {number} timeoutMs - The exceeded budget
|
|
429
586
|
*/
|
|
@@ -431,15 +588,19 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
431
588
|
phase = 'stopped';
|
|
432
589
|
connected = false;
|
|
433
590
|
const now = nowFn();
|
|
591
|
+
const note = `Stop took longer than ${timeoutMs}ms — forced.`;
|
|
434
592
|
lastStatus = 'yellow';
|
|
435
593
|
lastPhase = 'stopped';
|
|
436
594
|
lastCode = null;
|
|
595
|
+
printedStatus = 'yellow';
|
|
596
|
+
printedCode = null;
|
|
597
|
+
logger.warn( `winkComposer/mqttSource: source stopped: ${note}` );
|
|
437
598
|
emitStatus( {
|
|
438
599
|
status: 'yellow',
|
|
439
600
|
connected: false,
|
|
440
601
|
phase: 'stopped',
|
|
441
|
-
msSinceLastMsg: now - lastReceivedAt,
|
|
442
|
-
note
|
|
602
|
+
msSinceLastMsg: Math.round( now - lastReceivedAt ),
|
|
603
|
+
note
|
|
443
604
|
} );
|
|
444
605
|
emitMetrics();
|
|
445
606
|
},
|
|
@@ -474,9 +635,10 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
474
635
|
},
|
|
475
636
|
|
|
476
637
|
/**
|
|
477
|
-
* A payload could not be decoded.
|
|
478
|
-
* per-record report (ADR-018: skip,
|
|
479
|
-
* 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.
|
|
480
642
|
*
|
|
481
643
|
* @param {string} detail - Operator-facing description
|
|
482
644
|
*/
|
|
@@ -485,6 +647,7 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
485
647
|
skipped += 1;
|
|
486
648
|
lastReceivedAt = nowFn();
|
|
487
649
|
ringPush( 1 );
|
|
650
|
+
decodeLine( detail );
|
|
488
651
|
emitStatus( {
|
|
489
652
|
status: 'yellow',
|
|
490
653
|
connected,
|
|
@@ -502,17 +665,19 @@ const createStatusReporter = function ( options = {} ) {
|
|
|
502
665
|
},
|
|
503
666
|
|
|
504
667
|
/**
|
|
505
|
-
* The user's transform threw.
|
|
506
|
-
* report (skip, classify,
|
|
507
|
-
* source-transform-
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* 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.
|
|
511
675
|
*
|
|
512
676
|
* @param {string} detail - Operator-facing description
|
|
513
677
|
*/
|
|
514
678
|
transformFailed: function ( detail ) {
|
|
515
679
|
skipped += 1;
|
|
680
|
+
transformLine( detail );
|
|
516
681
|
emitStatus( {
|
|
517
682
|
status: 'yellow',
|
|
518
683
|
connected,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// core/source-manager/record-shape.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview One definition of "usable record" for every source.
|
|
5
|
+
*
|
|
6
|
+
* A source hands the pipeline record objects. A decoded payload or a
|
|
7
|
+
* transform return can instead be a scalar, null, undefined, or a
|
|
8
|
+
* bare array. All of those are valid JSON documents, so decoding
|
|
9
|
+
* alone cannot reject them. Attaching metadata to them throws in
|
|
10
|
+
* strict mode. This module gives every source the same two tools:
|
|
11
|
+
* the guard predicate, and the plain name of the rejected shape for
|
|
12
|
+
* the per-record report (skip-classify-continue, ADR-018).
|
|
13
|
+
*
|
|
14
|
+
* Kept as one shared module so the MQTT and CSV sources cannot
|
|
15
|
+
* drift apart on what a usable record is (the cross-source
|
|
16
|
+
* transform contract).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tell whether a decoded value can carry pipeline fields: a real
|
|
21
|
+
* object, not null, not an array. Hot-path cost is one comparison,
|
|
22
|
+
* one typeof, and one Array.isArray — no allocation.
|
|
23
|
+
*
|
|
24
|
+
* @param {*} value - Decoded payload or transform return
|
|
25
|
+
* @returns {boolean} True when the value is a usable record
|
|
26
|
+
*/
|
|
27
|
+
const isUsableRecord = function ( value ) {
|
|
28
|
+
return ( value !== null ) && ( typeof value === 'object' ) && !Array.isArray( value );
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Name a rejected value's shape for an operator-facing report.
|
|
33
|
+
* Called on the failure path only, so the string cost is fine.
|
|
34
|
+
*
|
|
35
|
+
* @param {*} value - The rejected value
|
|
36
|
+
* @returns {string} Plain shape name, e.g. 'a number', 'an array'
|
|
37
|
+
*/
|
|
38
|
+
const describeShape = function ( value ) {
|
|
39
|
+
if ( value === null ) {
|
|
40
|
+
return 'null';
|
|
41
|
+
}
|
|
42
|
+
if ( value === undefined ) {
|
|
43
|
+
return 'undefined';
|
|
44
|
+
}
|
|
45
|
+
if ( Array.isArray( value ) ) {
|
|
46
|
+
return 'an array';
|
|
47
|
+
}
|
|
48
|
+
return `a ${typeof value}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// EXPORTS
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export { isUsableRecord, describeShape };
|
|
@@ -80,6 +80,6 @@ export const generateField = function ( fieldSpec, prng, msgIndex, intervalMs )
|
|
|
80
80
|
default:
|
|
81
81
|
// Unreachable in practice — validate.js rejects unknown
|
|
82
82
|
// types at startup. Kept for clarity at the boundary.
|
|
83
|
-
throw new Error( `testHarness: unknown field type "${fieldSpec.type}"` );
|
|
83
|
+
throw new Error( `winkComposer/testHarness: unknown field type "${fieldSpec.type}"` );
|
|
84
84
|
}
|
|
85
85
|
};
|