@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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/composer.js +1 -1
  4. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  5. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  6. package/src/core/emitter-manager/mqtt/emitter.js +103 -658
  7. package/src/core/emitter-manager/mqtt/health.js +151 -0
  8. package/src/core/emitter-manager/mqtt/index.js +68 -4
  9. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  10. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  11. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  12. package/src/core/emitter-manager/terminal/index.js +16 -1
  13. package/src/core/env-vars.js +99 -13
  14. package/src/core/logger/index.js +1 -1
  15. package/src/core/source-manager/mqtt/client.js +197 -63
  16. package/src/core/source-manager/mqtt/dedup.js +16 -11
  17. package/src/core/source-manager/mqtt/index.js +32 -5
  18. package/src/core/source-manager/mqtt/status.js +168 -56
  19. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  20. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  21. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  22. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  23. package/src/core/storage-manager/questdb/index.js +574 -847
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +403 -0
  26. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  27. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  28. package/src/core/test-utils/tcp-proxy.js +194 -1
  29. package/src/core/utils/address/index.js +362 -0
  30. package/src/core/utils/address/probe.js +210 -0
  31. package/src/core/utils/callback-guard/index.js +23 -2
  32. package/src/core/utils/clock/index.js +34 -0
  33. package/src/core/utils/jitter/index.js +51 -0
  34. package/src/core/utils/line-rate/index.js +104 -0
  35. package/src/core/utils/quantize/index.js +43 -19
  36. package/src/core/wiring/wire-emitters.js +3 -1
  37. package/src/core/wiring/wire-storages.js +10 -2
  38. package/src/flow/flow.js +24 -3
  39. package/src/flow/run.js +24 -2
@@ -0,0 +1,362 @@
1
+ // core/utils/address/index.js
2
+
3
+ /**
4
+ * @fileoverview The shared address helper: parse, classify, and describe
5
+ * the addresses adapters connect to.
6
+ *
7
+ * Every adapter takes an address. QuestDB takes `ilpUrl` and `pgUrl`
8
+ * in the `host:port` grammar. The two MQTT adapters take `brokerUrl`
9
+ * in the URL grammar (`mqtt://host:port`). This module is the one
10
+ * place that reads those strings, so the three refusal layers (the
11
+ * adapter schema, the adapter factory, and `env-vars.js`) and the
12
+ * three adapters all reach the same verdict about one value.
13
+ *
14
+ * Why it exists (ADR-030): `localhost` is a name, not an address. On
15
+ * most systems it resolves to two addresses, `::1` and `127.0.0.1`,
16
+ * and a service may listen on only one. The run-5 soak lost its write
17
+ * path for four hours that way. So `localhost` is refused wherever an
18
+ * adapter address is read, and any other name gets one warning. This
19
+ * module supplies the classification and the two message strings; it
20
+ * never prints and never opens a socket. The setup probe that does open
21
+ * a socket lives beside it in `probe.js` (ADR-030 item 4), so this file
22
+ * stays pure.
23
+ *
24
+ * Classification rules:
25
+ * - An IP literal (`net.isIP` returns 4 or 6) is `ip`.
26
+ * - `localhost` in any letter case, with or without a trailing dot, is
27
+ * `localhost`. So is any name under the reserved `.localhost`
28
+ * top-level domain (RFC 6761 §6.3 reserves it for loopback).
29
+ * - Everything else is `name`.
30
+ * - A value the grammar cannot read is `unparsed`. It is neither
31
+ * refused nor warned here; the transport library owns that error.
32
+ *
33
+ * The functions never throw on an address value. `classifyAddress`
34
+ * throws only on an unknown grammar, which is a programming error.
35
+ *
36
+ * Setup path only. Nothing here runs per message.
37
+ */
38
+
39
+ import net from 'node:net';
40
+
41
+ const LOOPBACK_LITERAL = '127.0.0.1';
42
+ const MAX_PORT = 65535;
43
+ const LOCALHOST = 'localhost';
44
+ const LOCALHOST_SUFFIX = '.localhost';
45
+ const GRAMMARS = new Set( [ 'hostPort', 'url' ] );
46
+
47
+ // A host in the plain form has no colon, slash, or whitespace. The
48
+ // port is digits only. Both patterns are single-quantifier on purpose:
49
+ // the security lint rejects a nested quantifier, so the host and the
50
+ // port are split by hand on the first colon and each part is tested
51
+ // alone.
52
+ const HOST_CHARS = /^[^:\/\s]+$/;
53
+ const DIGITS = /^\d+$/;
54
+
55
+ const MULTI_ADDRESS_REASON = 'can resolve to more than one address, and the service may answer on only one';
56
+
57
+ /**
58
+ * The result every parser returns for a value it cannot read.
59
+ *
60
+ * @param {*} value - The original value, echoed for the caller's message
61
+ * @returns {{unparsed: true, value: *}} The unparsed marker
62
+ */
63
+ const unparsed = function ( value ) {
64
+ return { unparsed: true, value };
65
+ }; // unparsed()
66
+
67
+ /**
68
+ * Completes a host:port parse once the host and the port text are
69
+ * known. The port must be digits only and at most 65535; otherwise the
70
+ * whole value is unparsed.
71
+ *
72
+ * @param {*} value - The original value, for the unparsed marker
73
+ * @param {string} host - The host text, brackets already removed
74
+ * @param {string|undefined} portText - The text after the colon, or undefined
75
+ * @param {boolean} bracketed - Whether the host was written in brackets
76
+ * @returns {Object} `{ host, port, bracketed }` or the unparsed marker
77
+ */
78
+ const finishHostPort = function ( value, host, portText, bracketed ) {
79
+ if ( portText === undefined ) {
80
+ return { host, port: undefined, bracketed };
81
+ }
82
+ if ( !DIGITS.test( portText ) ) {
83
+ return unparsed( value );
84
+ }
85
+ const port = Number( portText );
86
+ if ( port > MAX_PORT ) {
87
+ return unparsed( value );
88
+ }
89
+ return { host, port, bracketed };
90
+ }; // finishHostPort()
91
+
92
+ /**
93
+ * Parses the bracketed form: `[v6]` or `[v6]:port`. The brackets must
94
+ * hold an IPv6 literal, and only `:port` may follow them.
95
+ *
96
+ * @param {*} value - The original value, for the unparsed marker
97
+ * @param {string} text - The trimmed value, known to start with `[`
98
+ * @returns {Object} `{ host, port, bracketed }` or the unparsed marker
99
+ */
100
+ const parseBracketed = function ( value, text ) {
101
+ const close = text.indexOf( ']' );
102
+ if ( close === -1 ) {
103
+ return unparsed( value );
104
+ }
105
+ const host = text.slice( 1, close );
106
+ if ( net.isIP( host ) !== 6 ) {
107
+ return unparsed( value );
108
+ }
109
+ const rest = text.slice( close + 1 );
110
+ if ( rest === '' ) {
111
+ return { host, port: undefined, bracketed: true };
112
+ }
113
+ if ( !rest.startsWith( ':' ) ) {
114
+ return unparsed( value );
115
+ }
116
+ return finishHostPort( value, host, rest.slice( 1 ), true );
117
+ }; // parseBracketed()
118
+
119
+ /**
120
+ * Parses the `host:port` grammar. Accepts `host:port`, `[v6]:port`,
121
+ * a bare host, a bare bracketed IPv6 literal, and a bare unbracketed
122
+ * IPv6 literal. The port is optional in every form.
123
+ *
124
+ * @param {*} value - The configured value
125
+ * @returns {Object} `{ host, port, bracketed }` or `{ unparsed: true, value }`
126
+ */
127
+ const parseHostPort = function ( value ) {
128
+ if ( typeof value !== 'string' ) {
129
+ return unparsed( value );
130
+ }
131
+ const text = value.trim();
132
+ if ( text === '' ) {
133
+ return unparsed( value );
134
+ }
135
+ if ( text.startsWith( '[' ) ) {
136
+ return parseBracketed( value, text );
137
+ }
138
+
139
+ // A bare IPv6 literal has colons of its own, so it cannot carry a
140
+ // port in this grammar. Write `[v6]:port` for that.
141
+ if ( net.isIP( text ) === 6 ) {
142
+ return { host: text, port: undefined, bracketed: false };
143
+ }
144
+
145
+ // The plain form splits on the first colon. A second colon then
146
+ // lands in the port text, where the digits test rejects it.
147
+ const at = text.indexOf( ':' );
148
+ const host = ( at === -1 ) ? text : text.slice( 0, at );
149
+ const portText = ( at === -1 ) ? undefined : text.slice( at + 1 );
150
+ if ( !HOST_CHARS.test( host ) ) {
151
+ return unparsed( value );
152
+ }
153
+ return finishHostPort( value, host, portText, false );
154
+ }; // parseHostPort()
155
+
156
+ /**
157
+ * Parses the broker URL grammar with the platform URL parser. The
158
+ * brackets around an IPv6 host are removed. Userinfo (a user name or
159
+ * password in the URL) is reported as a flag and never copied out, so
160
+ * a password cannot travel into a message.
161
+ *
162
+ * @param {*} value - The configured value
163
+ * @returns {Object} `{ protocol, host, port, bracketed, hasUserinfo }` or `{ unparsed: true, value }`
164
+ */
165
+ const parseBrokerUrl = function ( value ) {
166
+ if ( typeof value !== 'string' ) {
167
+ return unparsed( value );
168
+ }
169
+ let url;
170
+ try {
171
+ url = new URL( value.trim() );
172
+ } catch {
173
+ return unparsed( value );
174
+ }
175
+ if ( url.hostname === '' ) {
176
+ return unparsed( value );
177
+ }
178
+
179
+ const bracketed = url.hostname.startsWith( '[' );
180
+ const host = bracketed ? url.hostname.slice( 1, -1 ) : url.hostname;
181
+ const port = ( url.port === '' ) ? undefined : Number( url.port );
182
+ const hasUserinfo = ( url.username !== '' ) || ( url.password !== '' );
183
+ return { protocol: url.protocol, host, port, bracketed, hasUserinfo };
184
+ }; // parseBrokerUrl()
185
+
186
+ /**
187
+ * Classifies one host as an IP literal, `localhost`, or a name.
188
+ *
189
+ * @param {string} host - A parsed host, brackets already removed
190
+ * @returns {'ip'|'localhost'|'name'} The classification
191
+ */
192
+ const classifyHost = function ( host ) {
193
+ if ( net.isIP( host ) !== 0 ) {
194
+ return 'ip';
195
+ }
196
+ const lowered = host.toLowerCase();
197
+ const normalised = lowered.endsWith( '.' ) ? lowered.slice( 0, -1 ) : lowered;
198
+ if ( ( normalised === LOCALHOST ) || normalised.endsWith( LOCALHOST_SUFFIX ) ) {
199
+ return 'localhost';
200
+ }
201
+ return 'name';
202
+ }; // classifyHost()
203
+
204
+ /**
205
+ * Parses and classifies one configured address in one call. This is
206
+ * the one check the three refusal layers share.
207
+ *
208
+ * @param {*} value - The configured value
209
+ * @param {'hostPort'|'url'} grammar - Which grammar the field uses
210
+ * @returns {Object} `{ kind, grammar, host, port, family, bracketed }`,
211
+ * plus `protocol` and `hasUserinfo` for the URL grammar. A value the
212
+ * grammar cannot read returns `{ kind: 'unparsed', grammar, value }`.
213
+ * @throws {TypeError} On an unknown grammar (a programming error)
214
+ */
215
+ const classifyAddress = function ( value, grammar ) {
216
+ if ( !GRAMMARS.has( grammar ) ) {
217
+ throw new TypeError( `winkComposer/address: unknown grammar '${grammar}' (expected hostPort or url)` );
218
+ }
219
+ const parsed = ( grammar === 'url' ) ? parseBrokerUrl( value ) : parseHostPort( value );
220
+ if ( parsed.unparsed ) {
221
+ return { kind: 'unparsed', grammar, value };
222
+ }
223
+ const address = {
224
+ kind: classifyHost( parsed.host ),
225
+ grammar,
226
+ host: parsed.host,
227
+ port: parsed.port,
228
+ family: net.isIP( parsed.host ),
229
+ bracketed: parsed.bracketed
230
+ };
231
+ if ( grammar === 'url' ) {
232
+ address.protocol = parsed.protocol;
233
+ address.hasUserinfo = parsed.hasUserinfo;
234
+ }
235
+ return address;
236
+ }; // classifyAddress()
237
+
238
+ /**
239
+ * Writes a host the way a config value would: an IPv6 literal in
240
+ * brackets, anything else as is.
241
+ *
242
+ * @param {string} host - The host text
243
+ * @returns {string} The host, bracketed when it is an IPv6 literal
244
+ */
245
+ const bracketIfIPv6 = function ( host ) {
246
+ return ( net.isIP( host ) === 6 ) ? `[${host}]` : host;
247
+ }; // bracketIfIPv6()
248
+
249
+ /**
250
+ * Joins a host text and an optional port.
251
+ *
252
+ * @param {string} hostText - The host, already bracketed if needed
253
+ * @param {number|undefined} port - The port, or undefined for none
254
+ * @returns {string} `host` or `host:port`
255
+ */
256
+ const withPort = function ( hostText, port ) {
257
+ return ( port === undefined ) ? hostText : `${hostText}:${port}`;
258
+ }; // withPort()
259
+
260
+ /**
261
+ * Writes a host and an optional port the way a config value would:
262
+ * an IPv6 host in brackets, `host:port` when a port is given.
263
+ *
264
+ * @param {string} host - The host text
265
+ * @param {number|undefined} port - The port, or undefined for none
266
+ * @returns {string} `host`, `host:port`, or `[v6]:port`
267
+ */
268
+ const formatHostPort = function ( host, port ) {
269
+ return withPort( bracketIfIPv6( host ), port );
270
+ }; // formatHostPort()
271
+
272
+ /**
273
+ * Renders a classified address for a message. Userinfo in a URL is
274
+ * shown as `***@`, never as written.
275
+ *
276
+ * @param {Object} address - A `classifyAddress` result (not unparsed)
277
+ * @returns {string} The address as an operator would write it
278
+ */
279
+ const formatAddress = function ( address ) {
280
+ const hostPort = formatHostPort( address.host, address.port );
281
+ if ( address.grammar === 'url' ) {
282
+ const userinfo = address.hasUserinfo ? '***@' : '';
283
+ return `${address.protocol}//${userinfo}${hostPort}`;
284
+ }
285
+ return hostPort;
286
+ }; // formatAddress()
287
+
288
+ /**
289
+ * Suggests the same address with a literal host in place of the one
290
+ * configured. A URL suggestion drops userinfo, so a password never
291
+ * lands in a message.
292
+ *
293
+ * @param {Object} address - A `classifyAddress` result (not unparsed)
294
+ * @param {string} [literalHost='127.0.0.1'] - The literal to suggest
295
+ * @returns {string} The address to set
296
+ */
297
+ const suggestLiteral = function ( address, literalHost = LOOPBACK_LITERAL ) {
298
+ const hostPort = formatHostPort( literalHost, address.port );
299
+ if ( address.grammar === 'url' ) {
300
+ return `${address.protocol}//${hostPort}`;
301
+ }
302
+ return hostPort;
303
+ }; // suggestLiteral()
304
+
305
+ /**
306
+ * The message clause for a refused `localhost` address, in the ADR-028
307
+ * form `<message> [INVALID_CONFIG]: <detail>`. The caller prefixes its
308
+ * module token. Names the field, the literal to set, and the env var
309
+ * when the field has one.
310
+ *
311
+ * @param {Object} args - Message inputs
312
+ * @param {string} args.field - The config key (`ilpUrl`, `brokerUrl`)
313
+ * @param {Object} args.address - The classified address being refused
314
+ * @param {string} [args.envVar] - The environment variable that also sets it
315
+ * @returns {string} The message clause
316
+ */
317
+ const localhostRefusalMessage = function ( { field, address, envVar } ) {
318
+ const literal = suggestLiteral( address );
319
+ const where = ( envVar === undefined ) ? '' : ` (or ${envVar}=${literal})`;
320
+ return `${field} '${formatAddress( address )}' is refused [INVALID_CONFIG]: ` +
321
+ `'localhost' ${MULTI_ADDRESS_REASON}; set ${field} to ${literal}${where}`;
322
+ }; // localhostRefusalMessage()
323
+
324
+ /**
325
+ * The detail for `env-vars.js`, whose runner prefixes the variable
326
+ * name itself.
327
+ *
328
+ * @param {Object} address - The classified address being refused
329
+ * @returns {string} The detail sentence
330
+ */
331
+ const localhostRefusalDetail = function ( address ) {
332
+ return `'localhost' ${MULTI_ADDRESS_REASON}; use ${suggestLiteral( address )}`;
333
+ }; // localhostRefusalDetail()
334
+
335
+ /**
336
+ * The warning clause for a host that is a name. `ADDRESS_IS_NAME` is a
337
+ * console classification token, like `CALLBACK_FAILED`, not an
338
+ * `err.code` (ADR-030). The caller prefixes its module token.
339
+ *
340
+ * @param {Object} args - Message inputs
341
+ * @param {string} args.field - The config key
342
+ * @param {string} args.host - The name that was configured
343
+ * @returns {string} The warning clause
344
+ */
345
+ const nameWarningMessage = function ( { field, host } ) {
346
+ return `${field} host '${host}' is a name, not an address [ADDRESS_IS_NAME]: ` +
347
+ `a name ${MULTI_ADDRESS_REASON}; prefer the literal address`;
348
+ }; // nameWarningMessage()
349
+
350
+ export {
351
+ parseHostPort,
352
+ parseBrokerUrl,
353
+ classifyHost,
354
+ classifyAddress,
355
+ formatHostPort,
356
+ formatAddress,
357
+ suggestLiteral,
358
+ localhostRefusalMessage,
359
+ localhostRefusalDetail,
360
+ nameWarningMessage,
361
+ LOOPBACK_LITERAL
362
+ };
@@ -0,0 +1,210 @@
1
+ // core/utils/address/probe.js
2
+
3
+ /**
4
+ * @fileoverview The setup probe: does the configured address answer on
5
+ * every address it resolves to?
6
+ *
7
+ * Why it exists (ADR-030 item 4): a name such as `db.plant.local` can
8
+ * resolve to two addresses, `::1` and `127.0.0.1`, and a service may
9
+ * listen on only one. Node connects to the first address the resolver
10
+ * returns. On the run-5 rig the first address refused while the
11
+ * second answered, the flow ran on the second for 32 hours, and then
12
+ * the name stopped working with no change composer could see. So the
13
+ * QuestDB adapter probes both of its endpoints at setup, before any
14
+ * client is built, and fails setup unless every resolved address
15
+ * answers. The message names each address and its result and, when
16
+ * one answered, the literal to set.
17
+ *
18
+ * How it probes:
19
+ * - An IP literal is probed once. A name is resolved with Node's
20
+ * default lookup, all addresses, no family or order forced. That is
21
+ * the same resolve path the QuestDB client takes, so the probe sees
22
+ * what the client will see. A shell tool cannot stand in for it:
23
+ * `curl` and `nc` resolve and fall back differently from Node.
24
+ * - The lookup has the same time limit as a connect. A resolver pool
25
+ * that stalls is a real failure mode: on the run-5 rig one held
26
+ * every flush open for minutes. A lookup that does not answer in
27
+ * time fails the probe as `timeout`, and no address is dialled.
28
+ * - Every address is probed in resolver order, one at a time, with a
29
+ * time limit each. A probe is one TCP connect; the socket is
30
+ * destroyed the moment it answers, fails, or times out. The socket
31
+ * is not `unref`'d: an unref'd socket would let the process exit
32
+ * with the probe still pending when nothing else holds the event
33
+ * loop open, which is the case for a headless flow at setup. The
34
+ * time limit bounds the wait instead.
35
+ * - A late event after the first (an error after a connect) changes
36
+ * nothing; each attempt settles once.
37
+ * - Results are `answers`, `refused` (ECONNREFUSED), `timeout`
38
+ * (ETIMEDOUT or the time limit), or the Node error code as is.
39
+ *
40
+ * The lookup and the connect are injectable so the adapter specs run
41
+ * against scripted sockets. ADR-029's adapter calls the same probe
42
+ * once when a flush fails to connect, off the hot path, to attach a
43
+ * diagnosis to the report. Setup path only; nothing here runs per
44
+ * message.
45
+ */
46
+
47
+ import dns from 'node:dns';
48
+ import net from 'node:net';
49
+
50
+ import { formatHostPort, suggestLiteral } from './index.js';
51
+
52
+ const DEFAULT_PROBE_TIMEOUT_MS = 2000;
53
+
54
+ /**
55
+ * Names the outcome of a failed connect.
56
+ *
57
+ * @param {Error} err - The socket error
58
+ * @returns {string} `refused`, `timeout`, the Node code, or `error`
59
+ */
60
+ const resultForError = function ( err ) {
61
+ if ( err.code === 'ECONNREFUSED' ) {
62
+ return 'refused';
63
+ }
64
+ if ( err.code === 'ETIMEDOUT' ) {
65
+ return 'timeout';
66
+ }
67
+ return err.code || 'error';
68
+ }; // resultForError()
69
+
70
+ /**
71
+ * Builds the one-address dialler over the injected connect.
72
+ *
73
+ * @param {function} connectFn - `net.connect`-shaped: `( { host, port } ) => socket`
74
+ * @param {number} timeoutMs - The time limit per attempt
75
+ * @returns {function} `( address, port ) => Promise<string>` resolving to the result name
76
+ */
77
+ const makeDial = function ( connectFn, timeoutMs ) {
78
+ return function ( address, port ) {
79
+ return new Promise( ( resolve ) => {
80
+ const socket = connectFn( { host: address, port } );
81
+ let settled = false;
82
+ const finish = function ( result ) {
83
+ if ( settled ) {
84
+ return;
85
+ }
86
+ settled = true;
87
+ socket.destroy();
88
+ resolve( result );
89
+ };
90
+ socket.once( 'connect', () => finish( 'answers' ) );
91
+ socket.once( 'error', ( err ) => finish( resultForError( err ) ) );
92
+ socket.setTimeout( timeoutMs, () => finish( 'timeout' ) );
93
+ } );
94
+ };
95
+ }; // makeDial()
96
+
97
+ /**
98
+ * Resolves a name within the time limit. The timer is cleared the
99
+ * moment the lookup settles, so a normal lookup leaves nothing behind.
100
+ * The timer is not `unref`'d, for the same reason as the socket above.
101
+ *
102
+ * @param {function} lookupFn - The resolver, `( host, { all: true } ) => Promise<targets>`
103
+ * @param {string} host - The name to resolve
104
+ * @param {number} timeoutMs - The time limit
105
+ * @returns {Promise<Array>} The resolved targets; rejects with code `timeout` at the limit
106
+ */
107
+ const lookupWithin = function ( lookupFn, host, timeoutMs ) {
108
+ return new Promise( ( resolve, reject ) => {
109
+ const pending = lookupFn( host, { all: true } );
110
+ const timer = setTimeout( () => {
111
+ const err = new Error( `lookup of '${host}' did not answer within ${timeoutMs} ms` );
112
+ err.code = 'timeout';
113
+ reject( err );
114
+ }, timeoutMs );
115
+ pending.then(
116
+ ( targets ) => {
117
+ clearTimeout( timer );
118
+ resolve( targets );
119
+ },
120
+ ( err ) => {
121
+ clearTimeout( timer );
122
+ reject( err );
123
+ }
124
+ );
125
+ } );
126
+ }; // lookupWithin()
127
+
128
+ /**
129
+ * Probes the targets one at a time, in order. Written as a recursion
130
+ * rather than a loop so no `await` sits inside a loop body; the depth
131
+ * is the number of resolved addresses, two or three in practice.
132
+ *
133
+ * @param {Array<{address: string, family: number}>} targets - Resolved addresses
134
+ * @param {number} port - The port to probe
135
+ * @param {function} dial - The dialler from `makeDial`
136
+ * @param {Array} attempts - The results so far
137
+ * @returns {Promise<Array>} One `{ address, family, result }` per target
138
+ */
139
+ const probeEach = async function ( targets, port, dial, attempts ) {
140
+ if ( attempts.length === targets.length ) {
141
+ return attempts;
142
+ }
143
+ const target = targets[ attempts.length ];
144
+ const result = await dial( target.address, port );
145
+ attempts.push( { address: target.address, family: target.family, result } );
146
+ return probeEach( targets, port, dial, attempts );
147
+ }; // probeEach()
148
+
149
+ /**
150
+ * Probes one classified address. Passes only when every resolved
151
+ * address answers.
152
+ *
153
+ * @param {Object} address - A `classifyAddress` result of kind `ip` or `name`, with a port
154
+ * @param {Object} [options] - Injection points
155
+ * @param {function} [options.lookupFn=dns.promises.lookup] - The resolver
156
+ * @param {function} [options.connectFn=net.connect] - The connect
157
+ * @param {number} [options.timeoutMs=2000] - The time limit per address, and for the lookup
158
+ * @returns {Promise<Object>} `{ ok, host, port, attempts }`, or
159
+ * `{ ok: false, host, port, lookupError, attempts: [] }` when the name did not resolve in time
160
+ */
161
+ const probeAddress = async function ( address, options = {} ) {
162
+ const {
163
+ lookupFn = dns.promises.lookup,
164
+ connectFn = net.connect,
165
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS
166
+ } = options;
167
+ const dial = makeDial( connectFn, timeoutMs );
168
+
169
+ let targets;
170
+ if ( address.kind === 'ip' ) {
171
+ targets = [ { address: address.host, family: address.family } ];
172
+ } else {
173
+ try {
174
+ targets = await lookupWithin( lookupFn, address.host, timeoutMs );
175
+ } catch ( err ) {
176
+ return { ok: false, host: address.host, port: address.port, lookupError: err.code || err.message, attempts: [] };
177
+ }
178
+ }
179
+
180
+ const attempts = await probeEach( targets, address.port, dial, [] );
181
+ const ok = attempts.every( ( attempt ) => attempt.result === 'answers' );
182
+ return { ok, host: address.host, port: address.port, attempts };
183
+ }; // probeAddress()
184
+
185
+ /**
186
+ * Renders a probe outcome for an operator: each address with its
187
+ * result and, when one answered but another did not, the literal to
188
+ * set. The rig's own case reads
189
+ * `[::1]:9000 refused, 127.0.0.1:9000 answers; set ilpUrl to 127.0.0.1:9000`.
190
+ *
191
+ * @param {Object} outcome - A `probeAddress` result
192
+ * @param {string} field - The config key, for the suggestion
193
+ * @param {Object} address - The classified address that was probed
194
+ * @returns {string} The detail sentence
195
+ */
196
+ const describeProbe = function ( outcome, field, address ) {
197
+ if ( outcome.lookupError !== undefined ) {
198
+ return `'${outcome.host}' did not resolve (${outcome.lookupError})`;
199
+ }
200
+ const listed = outcome.attempts
201
+ .map( ( attempt ) => `${formatHostPort( attempt.address, outcome.port )} ${attempt.result}` )
202
+ .join( ', ' );
203
+ const answering = outcome.attempts.find( ( attempt ) => attempt.result === 'answers' );
204
+ if ( outcome.ok || ( answering === undefined ) ) {
205
+ return listed;
206
+ }
207
+ return `${listed}; set ${field} to ${suggestLiteral( address, answering.address )}`;
208
+ }; // describeProbe()
209
+
210
+ export { probeAddress, describeProbe, DEFAULT_PROBE_TIMEOUT_MS };
@@ -32,9 +32,22 @@
32
32
  * thrown value. When the report closure itself fails, the guard
33
33
  * falls back to one bare log line with no user-value
34
34
  * interpolation.
35
+ * - The fault report is bounded per wrapped callback (ADR-029, the
36
+ * bounded loss line). A callback that fails on every call would
37
+ * print one line per call, which for a status callback on a busy
38
+ * source is a line per message. So the first two faults of an
39
+ * episode report in full, later faults are counted, and one summary
40
+ * reports per minute with the count. A quiet minute ends the
41
+ * episode. The summary goes through the site's own channel with the
42
+ * count appended to the detail, so no site changes.
35
43
  */
36
44
 
37
45
  import { logger } from '../../logger/index.js';
46
+ import { createLineBound } from '../line-rate/index.js';
47
+
48
+ /** The bound on the fault report (see the file header). */
49
+ const FULL_FAULT_LINES_PER_EPISODE = 2;
50
+ const FAULT_SUMMARY_INTERVAL_MS = 60000;
38
51
 
39
52
  /**
40
53
  * Frozen sentinel `wrapTransform` returns when the transform threw.
@@ -92,9 +105,17 @@ const wrapCallback = function ( fn, { name, severity, report } ) {
92
105
  if ( typeof fn !== 'function' ) {
93
106
  return null;
94
107
  }
108
+ const bounded = createLineBound( {
109
+ fullLines: FULL_FAULT_LINES_PER_EPISODE,
110
+ intervalMs: FAULT_SUMMARY_INTERVAL_MS,
111
+ printFull: report,
112
+ printSummary: function ( count, seconds, sev, callbackName, detail ) {
113
+ report( sev, callbackName, `${detail}; ${count} more fault(s) in the last ${seconds} s` );
114
+ }
115
+ } );
95
116
  const faultFn = function ( err ) {
96
117
  try {
97
- report( severity, name, describeFault( err ) );
118
+ bounded( severity, name, describeFault( err ) );
98
119
  } catch {
99
120
  reportFallback();
100
121
  }
@@ -152,4 +173,4 @@ const wrapTransform = function ( fn, onFault ) {
152
173
  };
153
174
  }; // wrapTransform()
154
175
 
155
- export { wrapCallback, wrapTransform, TRANSFORM_THREW };
176
+ export { wrapCallback, wrapTransform, TRANSFORM_THREW, FULL_FAULT_LINES_PER_EPISODE, FAULT_SUMMARY_INTERVAL_MS };
@@ -0,0 +1,34 @@
1
+ // core/utils/clock/index.js
2
+
3
+ /**
4
+ * @fileoverview The stopwatch clock for time rules that must not
5
+ * follow the wall clock.
6
+ *
7
+ * `Date.now()` reads the wall clock, and the wall clock can step. NTP
8
+ * corrects a board after boot or after a long time without network,
9
+ * and an operator can set the time by hand. A rule such as "red after
10
+ * 30 s disconnected" or "expire a dedup entry after 120 s" would then
11
+ * fire early or late by the size of the step. `performance.now()`
12
+ * reads a clock that only counts up, from process start, in
13
+ * milliseconds with a fraction. A step in the wall clock does not
14
+ * move it (ADR-018, long-running stability).
15
+ *
16
+ * The function reads `globalThis.performance` at every call, for two
17
+ * reasons. The method must be called on its own object, so the bare
18
+ * method cannot be passed around as a clock. And a test that fakes the
19
+ * stopwatch replaces the global object rather than patching the
20
+ * method, so a captured reference would keep reading the real clock.
21
+ *
22
+ * Use this clock for durations and expiry. Use `Date.now()` for
23
+ * timestamps that leave the process, such as a record's time field.
24
+ */
25
+
26
+ /**
27
+ * Reads the stopwatch clock.
28
+ *
29
+ * @returns {number} Milliseconds since process start, with a fraction;
30
+ * never decreases.
31
+ */
32
+ export const monotonicNow = function () {
33
+ return globalThis.performance.now();
34
+ }; // monotonicNow()