@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
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Requires Node.js 22 or newer.
|
|
|
43
43
|
The Quick Start flow replays a CSV feed of pump motor temperatures. It cleans each reading, flags a hot motor, confirms the heat is not a blip, and prints an alert. One command scaffolds it as a runnable project:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
npm create @winkjs/composer
|
|
46
|
+
npm create @winkjs/composer@latest
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Accept the directory prompt, then run the three printed steps. A run takes about eight seconds. Watch for the `overheat` alert. Prefer zero install? [Build this flow interactively](https://composer.winkjs.org/docs/playground/hello-flow) in the browser playground. The same project also lives in the repository, in [`examples/hello-flow`](examples/hello-flow).
|
|
@@ -123,7 +123,7 @@ Reproduce them from a repo clone. `node benchmark/compare.js 10 500` reports the
|
|
|
123
123
|
|
|
124
124
|
Write a flow once and run it anywhere Node.js does — an industrial-grade Raspberry Pi, a production server, a Kubernetes cluster. Each asset's state is isolated, so a fault in one stays in one. Messages [queue locally](docs/handbook/resilience.md) when the network drops and drain cleanly on reconnect. Shutdown is ordered and deterministic: sources close first, storage last. A misconfigured flow fails when you define it, not in production — unknown options, output collisions, and bad triggers are all caught at definition time.
|
|
125
125
|
|
|
126
|
-
The test suite holds over
|
|
126
|
+
The test suite holds over 7,000 tests behind a 100% coverage gate on statements, branches, functions, and lines. Integration tests run against real Mosquitto and QuestDB services, not mocks. Every npm release carries SLSA provenance — a public, verifiable link from the package back to the exact source commit and the build that produced it. `npm audit signatures` verifies it.
|
|
127
127
|
|
|
128
128
|
The documentation also relates winkComposer's concepts to two industrial standards: [ISO 13374](https://composer.winkjs.org/docs/reference/iso-13374-mapping) for condition monitoring, the [NIST AI RMF](https://composer.winkjs.org/docs/reference/nist-ai-rmf-mapping) for AI risk management.
|
|
129
129
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@winkjs/composer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Composable Streaming Intelligence — turn streaming data into actionable insights, from edge to cloud",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"streaming",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"sinon": "^19.0.2"
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"@questdb/nodejs-client": "
|
|
73
|
+
"@questdb/nodejs-client": "~4.2.0",
|
|
74
74
|
"glob": "^13.0.6",
|
|
75
75
|
"mqtt": "^5.15.1",
|
|
76
76
|
"msgpackr": "^1.11.10",
|
package/src/composer.js
CHANGED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// core/emitter-manager/mqtt/connection.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview The MQTT emitter's connection: opening the client,
|
|
5
|
+
* the bounded first-connack wait, and the event handlers that keep
|
|
6
|
+
* `state.connected` and the counters current.
|
|
7
|
+
*
|
|
8
|
+
* Reconnection stays the library's job (ADR-018). mqtt.js retries at
|
|
9
|
+
* its configured period for as long as the process lives. This module
|
|
10
|
+
* only listens: `connect` marks the link up and counts every connack
|
|
11
|
+
* after the first as a reconnect, `offline` marks it down, and
|
|
12
|
+
* `error` counts the event. Nothing here touches the message path.
|
|
13
|
+
*
|
|
14
|
+
* What the operator reads. Two layers of lines print through the
|
|
15
|
+
* logger facade, with or without `debug`, because a broker outage
|
|
16
|
+
* must reach a production log (ADR-018, the two-party rule).
|
|
17
|
+
*
|
|
18
|
+
* Edge lines, token `DELIVERY_HEALTH`, print once per change of link
|
|
19
|
+
* state. Going offline prints at error with the in-flight count. The
|
|
20
|
+
* next connack prints at warn with the outage length and the count.
|
|
21
|
+
* Nothing prints while a state persists, however long the outage
|
|
22
|
+
* lasts. The library fires `offline` once per outage, and a repeat
|
|
23
|
+
* would print nothing, because the state did not change. A client
|
|
24
|
+
* that never connected prints the same offline line on its first
|
|
25
|
+
* failure, because the change of state is the same.
|
|
26
|
+
*
|
|
27
|
+
* Attempt lines, token `CONNECT_FAILED`, print at warn from the
|
|
28
|
+
* client's `error` event. That event names the reason an edge line
|
|
29
|
+
* cannot: a refused connect, a connack timeout, or a keepalive
|
|
30
|
+
* timeout just before the link drops. The shared line bound keeps
|
|
31
|
+
* two per episode in full, then one summary a minute, so a night of
|
|
32
|
+
* retries every five seconds costs one line a minute, not one every
|
|
33
|
+
* five seconds. A stream
|
|
34
|
+
* error without a `code` never reaches this handler; the library
|
|
35
|
+
* swallows it (mqtt.js 5.15.1, `client.js:294-305`).
|
|
36
|
+
*
|
|
37
|
+
* The three `debug` lines stay beside these, at info and error.
|
|
38
|
+
*
|
|
39
|
+
* The startup posture is recovering (ADR-018). `openClient` never
|
|
40
|
+
* fails because the broker is unreachable. Only a URL the library
|
|
41
|
+
* itself refuses is a setup failure, classified `INVALID_CONFIG`.
|
|
42
|
+
*
|
|
43
|
+
* @see ADR-018
|
|
44
|
+
* @see ADR-028
|
|
45
|
+
* @see ADR-030
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { logger } from '../../logger/index.js';
|
|
49
|
+
import { nameWarningMessage } from '../../utils/address/index.js';
|
|
50
|
+
import { createLineBound } from '../../utils/line-rate/index.js';
|
|
51
|
+
import { monotonicNow } from '../../utils/clock/index.js';
|
|
52
|
+
import { invalidConfig } from './resolve-config.js';
|
|
53
|
+
|
|
54
|
+
/** How many failed attempts per episode print in full. */
|
|
55
|
+
const FULL_ATTEMPT_LINES_PER_EPISODE = 2;
|
|
56
|
+
|
|
57
|
+
/** The attempt summary interval, also the quiet gap that ends an episode. */
|
|
58
|
+
const ATTEMPT_SUMMARY_INTERVAL_MS = 60000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Prints the one ADDRESS_IS_NAME line when the broker host is a name.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} address - The classified broker address
|
|
64
|
+
*/
|
|
65
|
+
const warnIfBrokerIsName = function ( address ) {
|
|
66
|
+
if ( address.kind === 'name' ) {
|
|
67
|
+
logger.warn(
|
|
68
|
+
`winkComposer/mqttEmitter: ${nameWarningMessage( { field: 'brokerUrl', host: address.host } )}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}; // warnIfBrokerIsName()
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates the MQTT client. mqtt.connect throws its own raw TypeError
|
|
75
|
+
* on a malformed url; classify it like every other setup failure. The
|
|
76
|
+
* error text uses the redacted url — a malformed url can still carry
|
|
77
|
+
* credentials. The name warning goes first, so it prints before any
|
|
78
|
+
* socket opens (ADR-030).
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} resolved - The resolved configuration
|
|
81
|
+
* @param {string} resolved.brokerUrl - The broker URL
|
|
82
|
+
* @param {Object} resolved.brokerAddress - Its classified address
|
|
83
|
+
* @param {string} resolved.redactedBrokerUrl - The URL with credentials hidden
|
|
84
|
+
* @param {Object} resolved.mqttOptions - The connect options
|
|
85
|
+
* @param {function} resolved.connect - `mqtt.connect` or the injected connect function
|
|
86
|
+
* @returns {Object} the mqtt.js client
|
|
87
|
+
* @throws {TypeError} INVALID_CONFIG when the client refuses the URL
|
|
88
|
+
*/
|
|
89
|
+
export const openClient = function ( { brokerUrl, brokerAddress, redactedBrokerUrl, mqttOptions, connect } ) {
|
|
90
|
+
warnIfBrokerIsName( brokerAddress );
|
|
91
|
+
try {
|
|
92
|
+
return connect( brokerUrl, mqttOptions );
|
|
93
|
+
} catch ( connectErr ) {
|
|
94
|
+
throw invalidConfig( `brokerUrl rejected by the MQTT client ('${redactedBrokerUrl}'): ${connectErr.message}` );
|
|
95
|
+
}
|
|
96
|
+
}; // openClient()
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bounded wait for the client's first 'connect' event (the broker's
|
|
100
|
+
* connection acknowledgment). Resolves when the event fires or when
|
|
101
|
+
* graceMs elapses, whichever comes first. Expiry is NOT an error —
|
|
102
|
+
* the startup posture stays 'recovering' (ADR-018): the caller gets
|
|
103
|
+
* a working handle that reports `connected: false` while mqtt.js
|
|
104
|
+
* retries in the background. The one-shot listener is removed on
|
|
105
|
+
* expiry so a late connack reaches only the permanent state handler;
|
|
106
|
+
* the timer is cleared on connect so nothing pins the event loop.
|
|
107
|
+
*
|
|
108
|
+
* @param {Object} client - mqtt.js client (EventEmitter surface)
|
|
109
|
+
* @param {number} graceMs - positive wait budget, milliseconds
|
|
110
|
+
* @returns {Promise<void>} resolves on first connect or expiry; never rejects
|
|
111
|
+
*/
|
|
112
|
+
export const waitForFirstConnect = function ( client, graceMs ) {
|
|
113
|
+
return new Promise( ( resolve ) => {
|
|
114
|
+
let timer = null;
|
|
115
|
+
const onConnect = function () {
|
|
116
|
+
clearTimeout( timer );
|
|
117
|
+
resolve();
|
|
118
|
+
};
|
|
119
|
+
timer = setTimeout( () => {
|
|
120
|
+
client.removeListener( 'connect', onConnect );
|
|
121
|
+
resolve();
|
|
122
|
+
}, graceMs );
|
|
123
|
+
client.once( 'connect', onConnect );
|
|
124
|
+
} );
|
|
125
|
+
}; // waitForFirstConnect()
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds the bounded attempt line for one emitter.
|
|
129
|
+
*
|
|
130
|
+
* @returns {Function} `( message ) => void`, the line bound over the failure text
|
|
131
|
+
*/
|
|
132
|
+
const createAttemptLine = function () {
|
|
133
|
+
return createLineBound( {
|
|
134
|
+
fullLines: FULL_ATTEMPT_LINES_PER_EPISODE,
|
|
135
|
+
intervalMs: ATTEMPT_SUMMARY_INTERVAL_MS,
|
|
136
|
+
printFull: function ( message ) {
|
|
137
|
+
logger.warn( `winkComposer/mqttEmitter: connection attempt failed [CONNECT_FAILED]: ${message}` );
|
|
138
|
+
},
|
|
139
|
+
printSummary: function ( count, seconds, message ) {
|
|
140
|
+
logger.warn(
|
|
141
|
+
`winkComposer/mqttEmitter: connection attempt failed [CONNECT_FAILED]: ${message}; ${count} more attempt(s) failed in the last ${seconds} s`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
} );
|
|
145
|
+
}; // createAttemptLine()
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Attaches the permanent connection handlers. No automatic status:
|
|
149
|
+
* the handle's `getHealth()` reads `state`. The edge lines and the
|
|
150
|
+
* bounded attempt line print from here (see the file header).
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} deps
|
|
153
|
+
* @param {Object} deps.client - mqtt.js client
|
|
154
|
+
* @param {Object} deps.state - The emitter's core state
|
|
155
|
+
* @param {boolean} deps.debug - Whether the debug lines print
|
|
156
|
+
* @param {string} deps.redactedBrokerUrl - The URL with credentials hidden
|
|
157
|
+
*/
|
|
158
|
+
export const attachConnectionHandlers = function ( { client, state, debug, redactedBrokerUrl } ) {
|
|
159
|
+
// The link edge. `linkDown` is true from an offline event until
|
|
160
|
+
// the next connack; `downSince` is when that outage began on the
|
|
161
|
+
// stopwatch, for the restored line, so a step in the wall clock
|
|
162
|
+
// cannot change the length it names (ADR-018).
|
|
163
|
+
let linkDown = false;
|
|
164
|
+
let downSince = 0;
|
|
165
|
+
const attemptFailed = createAttemptLine();
|
|
166
|
+
|
|
167
|
+
client.on( 'connect', () => {
|
|
168
|
+
state.connected = true;
|
|
169
|
+
// mqtt.js fires 'connect' on every connack, including mid-run
|
|
170
|
+
// reconnects. Counting the later ones gives operators — and the
|
|
171
|
+
// release-soak signature policy — an observable "a reconnect
|
|
172
|
+
// happened during this run" fact via getHealth().stats.
|
|
173
|
+
if ( state.hasConnectedOnce ) {
|
|
174
|
+
state.stats.reconnects += 1;
|
|
175
|
+
}
|
|
176
|
+
state.hasConnectedOnce = true;
|
|
177
|
+
if ( linkDown ) {
|
|
178
|
+
linkDown = false;
|
|
179
|
+
const seconds = Math.round( ( monotonicNow() - downSince ) / 1000 );
|
|
180
|
+
logger.warn(
|
|
181
|
+
`winkComposer/mqttEmitter: broker connection restored after ${seconds} s, ${state.unacked} message(s) in flight [DELIVERY_HEALTH]`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if ( debug ) {
|
|
185
|
+
logger.info( `winkComposer/mqttEmitter: Connected to ${redactedBrokerUrl}` );
|
|
186
|
+
}
|
|
187
|
+
} );
|
|
188
|
+
|
|
189
|
+
client.on( 'offline', () => {
|
|
190
|
+
state.connected = false;
|
|
191
|
+
if ( debug ) {
|
|
192
|
+
logger.info( `winkComposer/mqttEmitter: Offline - ${state.unacked} messages in flight` );
|
|
193
|
+
}
|
|
194
|
+
if ( linkDown ) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
linkDown = true;
|
|
198
|
+
downSince = monotonicNow();
|
|
199
|
+
logger.error( `winkComposer/mqttEmitter: broker offline, ${state.unacked} message(s) in flight [DELIVERY_HEALTH]` );
|
|
200
|
+
} );
|
|
201
|
+
|
|
202
|
+
client.on( 'error', ( err ) => {
|
|
203
|
+
state.stats.errors += 1;
|
|
204
|
+
attemptFailed( err.message );
|
|
205
|
+
if ( debug ) {
|
|
206
|
+
logger.error( `winkComposer/mqttEmitter: client error: ${err.message}` );
|
|
207
|
+
}
|
|
208
|
+
} );
|
|
209
|
+
}; // attachConnectionHandlers()
|
|
@@ -49,53 +49,67 @@ export const DEFAULT_MAX_QUEUE_BYTES = 52428800; // 50 MB
|
|
|
49
49
|
// Operational constant — not configurable (design decision)
|
|
50
50
|
export const QUEUE_CRITICAL_THRESHOLD = 0.8;
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Hard ceiling on maxQueueSize, not configurable (design decision).
|
|
54
|
+
* MQTT packet ids are 16-bit, so at most 65,535 publishes can be
|
|
55
|
+
* unacknowledged at once. A queue sized beyond that promises capacity
|
|
56
|
+
* the protocol cannot deliver. With the default id allocator the id
|
|
57
|
+
* space wraps, and a new packet OVERWRITES an unacknowledged packet's
|
|
58
|
+
* store entry. That destroys the only copy of an undelivered QoS-1
|
|
59
|
+
* message, measured at about 1 per 700,000 under sustained load in
|
|
60
|
+
* the soak gate.
|
|
61
|
+
*
|
|
62
|
+
* The emitter uses the client's UniqueMessageIdProvider, which never
|
|
63
|
+
* reissues an id that is in use. So the unacked window must never
|
|
64
|
+
* hold more messages than the id space has ids. 60,000 leaves margin
|
|
65
|
+
* for ids stuck on packets that are never acknowledged.
|
|
66
|
+
* @type {number}
|
|
67
|
+
*/
|
|
63
68
|
export const MQTT_INFLIGHT_ID_LIMIT = 60000;
|
|
64
69
|
|
|
65
|
-
// Message expiry intervals (seconds)
|
|
66
|
-
|
|
70
|
+
// Message expiry intervals (seconds), indexed by the caller's
|
|
71
|
+
// `options.type`. A dictionary indexed by an external key has no
|
|
72
|
+
// prototype, so a key such as 'constructor' finds nothing and falls
|
|
73
|
+
// back to the default. With a plain object that key resolved to a
|
|
74
|
+
// function. mqtt-packet then destroyed the connection with an error
|
|
75
|
+
// that mqtt.js swallows (the emitter header, design decision 3).
|
|
76
|
+
export const MESSAGE_EXPIRY = Object.assign( Object.create( null ), {
|
|
67
77
|
telemetry: 3600, // 1 hour - sensor data loses value quickly
|
|
68
78
|
status: 86400, // 24 hours - status needs longer persistence
|
|
69
79
|
default: ENV_VARS.mqttMsgExpiry // Default expiry from ENV_VARS
|
|
70
|
-
};
|
|
80
|
+
} );
|
|
71
81
|
|
|
72
|
-
// MQTT v5 configuration
|
|
82
|
+
// MQTT v5 configuration. The reconnect period here is the floor;
|
|
83
|
+
// resolve-config.js adds the per-client jitter when it builds the
|
|
84
|
+
// connect options.
|
|
73
85
|
export const MQTT_CONFIG = {
|
|
74
86
|
protocolVersion: MQTT_PROTOCOL_V,
|
|
75
87
|
keepalive: ENV_VARS.mqttKeepalive,
|
|
76
88
|
reconnectPeriod: ENV_VARS.mqttReconnectMs,
|
|
77
89
|
connectTimeout: ENV_VARS.mqttConnectTimeoutMs,
|
|
90
|
+
|
|
78
91
|
// A deliberately clean session, spelled with the key mqtt.js
|
|
79
|
-
// actually reads (`clean`).
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
92
|
+
// actually reads (`clean`). mqtt.js silently ignores the MQTT 5
|
|
93
|
+
// spelling `cleanStart`. The source carried that exact defect
|
|
94
|
+
// until 2026-07-09, when the ADR-022 dedup soak caught it (see
|
|
95
|
+
// the cleanStart→clean mapping in source-manager/mqtt/client.js).
|
|
96
|
+
|
|
84
97
|
// Why clean: the emitter is publish-only. It holds no
|
|
85
|
-
// subscriptions a broker session could preserve
|
|
98
|
+
// subscriptions a broker session could preserve. Its
|
|
86
99
|
// unacknowledged messages live in the mqtt.js client's in-memory
|
|
87
100
|
// store inside this process, tracked by composer's unacked
|
|
88
|
-
// counter (ADR-021).
|
|
89
|
-
|
|
90
|
-
// key plus a random per-start
|
|
91
|
-
// session at the broker on every
|
|
92
|
-
//
|
|
93
|
-
// the only adapter that needs
|
|
101
|
+
// counter (ADR-021). A broker session buys it nothing.
|
|
102
|
+
|
|
103
|
+
// Before this fix, the ignored key plus a random per-start
|
|
104
|
+
// clientId parked a 7-day orphan session at the broker on every
|
|
105
|
+
// restart. `sessionExpiryInterval` rode along here as the same
|
|
106
|
+
// dead weight. The source is the only adapter that needs
|
|
107
|
+
// MQTT_SESSION_EXPIRY_S.
|
|
94
108
|
clean: true
|
|
95
109
|
};
|
|
96
110
|
|
|
97
|
-
// Storage configuration — operational constant for the DORMANT
|
|
98
|
-
// store
|
|
111
|
+
// Storage configuration — an operational constant for the DORMANT
|
|
112
|
+
// LevelDB store. Kept for the WAL successor, not wired (ADR-021).
|
|
99
113
|
export const STORE_CONFIG = {
|
|
100
114
|
compression: true,
|
|
101
115
|
cacheSize: 8 * 1024 * 1024 // 8MB LevelDB cache
|