@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,51 @@
1
+ // core/utils/jitter/index.js
2
+
3
+ /**
4
+ * @fileoverview Adds a random share to a reconnect period, once per
5
+ * client, so a fleet does not retry in step.
6
+ *
7
+ * mqtt.js retries at a fixed period. When one broker goes away, every
8
+ * client that used it fails at the same moment and retries on the same
9
+ * beat. Attempt `k` from every client lands on the recovering broker
10
+ * at `k` times the period, all at once. This module gives each client
11
+ * its own period: the configured one plus a random share of up to a
12
+ * fifth of it. The share is drawn once at setup, and because attempt
13
+ * `k` fires at `k` times the period, a fixed offset spreads the fleet
14
+ * further with each attempt.
15
+ *
16
+ * The configured period is the operator's floor. The helper never
17
+ * shortens it, and a period of zero stays zero, so a reconnect that
18
+ * was switched off stays off. Both MQTT adapters use this module; the
19
+ * reconnect loop itself stays the library's (ADR-018).
20
+ *
21
+ * The random source is a parameter, so a spec can name its result.
22
+ * Nothing here runs on a message path.
23
+ *
24
+ * @see ADR-018
25
+ */
26
+
27
+ /** The largest share of the period the jitter may add. */
28
+ export const RECONNECT_JITTER_FRACTION = 0.2;
29
+
30
+ /**
31
+ * Returns the period plus the floor of a random share of it.
32
+ *
33
+ * @param {number} periodMs - The configured period, a finite number of milliseconds, zero or more
34
+ * @param {Object} [options]
35
+ * @param {number} [options.fraction=RECONNECT_JITTER_FRACTION] - The largest share to add, in `[0, 1]`
36
+ * @param {Function} [options.random=Math.random] - Returns a number in `[0, 1)`
37
+ * @returns {number} `periodMs + Math.floor( random() * periodMs * fraction )`
38
+ * @throws {TypeError} when an argument is out of range
39
+ */
40
+ export const jitteredPeriod = function ( periodMs, { fraction = RECONNECT_JITTER_FRACTION, random = Math.random } = {} ) {
41
+ if ( ( typeof periodMs !== 'number' ) || !Number.isFinite( periodMs ) || ( periodMs < 0 ) ) {
42
+ throw new TypeError( `winkComposer/jitter: periodMs must be a finite number of milliseconds, zero or more, got ${String( periodMs )}` );
43
+ }
44
+ if ( ( typeof fraction !== 'number' ) || !( ( fraction >= 0 ) && ( fraction <= 1 ) ) ) {
45
+ throw new TypeError( `winkComposer/jitter: fraction must be a number in [0, 1], got ${String( fraction )}` );
46
+ }
47
+ if ( typeof random !== 'function' ) {
48
+ throw new TypeError( 'winkComposer/jitter: random must be a function' );
49
+ }
50
+ return periodMs + Math.floor( random() * periodMs * fraction );
51
+ }; // jitteredPeriod()
@@ -0,0 +1,104 @@
1
+ // core/utils/line-rate/index.js
2
+
3
+ /**
4
+ * @fileoverview Bounds how often a repeating event reaches the log.
5
+ *
6
+ * A dead sensor, a stuck callback, or an unreachable database produces
7
+ * the same event once per row. Printing every one fills a log file with
8
+ * one line per second for a whole night, and the line that matters is
9
+ * lost in the repeats. This module keeps the first lines of such an
10
+ * episode in full and then prints one summary per interval. An episode
11
+ * is a run of events with no quiet interval between them. A quiet
12
+ * interval ends the episode, so the next event prints in full again.
13
+ *
14
+ * The bound is a pure closure over a stopwatch reading, `monotonicNow`,
15
+ * so a step in the wall clock can neither end an episode nor move a
16
+ * summary (ADR-018, long-running stability). It holds no timer, so it
17
+ * needs no shutdown and cannot keep a process alive. The
18
+ * summary prints at the next event after the interval, never on a
19
+ * timer. So a burst that stops shows its count only when the next
20
+ * episode starts, or never. That is the accepted trade for zero timers.
21
+ *
22
+ * Two sites share this shape. The QuestDB adapter's flush-failure loss
23
+ * line uses the same algorithm inline (ADR-029, the bounded loss line).
24
+ * The adapter's default row-skip warning and the callback guard's
25
+ * `CALLBACK_FAILED` line both use this module.
26
+ */
27
+
28
+ import { monotonicNow } from '../clock/index.js';
29
+
30
+ /**
31
+ * Creates a line bound.
32
+ *
33
+ * The returned function takes the event's arguments, up to three, and
34
+ * forwards them by identity to `printFull` for the first `fullLines`
35
+ * events of an episode. What the arguments mean is the caller's
36
+ * business: the callback guard passes a severity, a callback name and
37
+ * a fault description; the row-skip warning passes a raw value and a
38
+ * partition id. Later events inside the interval are counted. The
39
+ * first event a full interval after the last printed line calls
40
+ * `printSummary` with the count since that line, the seconds elapsed,
41
+ * and the latest arguments. When an episode ends with counted events,
42
+ * their summary prints before the next episode's first full line.
43
+ *
44
+ * Three named parameters stand in for a rest parameter on purpose. A
45
+ * rest parameter builds an array on every call, and the row-skip
46
+ * warning runs once per skipped row.
47
+ *
48
+ * @param {Object} options
49
+ * @param {number} options.fullLines - How many events per episode print in full.
50
+ * @param {number} options.intervalMs - The summary interval, also the quiet gap that ends an episode.
51
+ * @param {Function} options.printFull - Called as `printFull( arg1, arg2, arg3 )` with the event's arguments.
52
+ * @param {Function} options.printSummary - Called as `printSummary( count, seconds, arg1, arg2, arg3 )`.
53
+ * @returns {Function} `( arg1, arg2, arg3 ) => void`.
54
+ */
55
+ export const createLineBound = function ( { fullLines, intervalMs, printFull, printSummary } ) {
56
+ let lastEventAt = 0;
57
+ let lastLineAt = 0;
58
+ let printed = 0;
59
+ let counted = 0;
60
+
61
+ // The state clears before the printer runs, as on the full-line
62
+ // path. A printer that throws then costs that one line only. With
63
+ // the old order a throw left the count and the stamp behind, and
64
+ // every later event ran straight into the printer again.
65
+ const summarize = function ( now, arg1, arg2, arg3 ) {
66
+ const seconds = Math.round( ( now - lastLineAt ) / 1000 );
67
+ const count = counted;
68
+ lastLineAt = now;
69
+ counted = 0;
70
+ printSummary( count, seconds, arg1, arg2, arg3 );
71
+ }; // summarize()
72
+
73
+ // The latest arguments are kept for the summary that closes an
74
+ // episode. They belong to the last counted event, not the new one.
75
+ let lastArg1;
76
+ let lastArg2;
77
+ let lastArg3;
78
+
79
+ return function ( arg1, arg2, arg3 ) {
80
+ const now = monotonicNow();
81
+ if ( ( now - lastEventAt ) >= intervalMs ) {
82
+ // A quiet interval ended the previous episode.
83
+ if ( counted > 0 ) {
84
+ summarize( now, lastArg1, lastArg2, lastArg3 );
85
+ }
86
+ printed = 0;
87
+ }
88
+ lastEventAt = now;
89
+ lastArg1 = arg1;
90
+ lastArg2 = arg2;
91
+ lastArg3 = arg3;
92
+ if ( printed < fullLines ) {
93
+ printed += 1;
94
+ lastLineAt = now;
95
+ printFull( arg1, arg2, arg3 );
96
+ return;
97
+ }
98
+ counted += 1;
99
+ if ( ( now - lastLineAt ) < intervalMs ) {
100
+ return;
101
+ }
102
+ summarize( now, arg1, arg2, arg3 );
103
+ };
104
+ }; // createLineBound()
@@ -11,24 +11,30 @@
11
11
  * for human reading. Both used to carry their own copy of the same
12
12
  * formula; this module is the single source.
13
13
  *
14
- * The quantizer pre-computes the inverse resolution and the appropriate
15
- * decimal-place count at build time, so the per-message hot path is one
16
- * multiply, one round, one multiply, one `toFixed` — no allocation, no
17
- * recomputation.
14
+ * The quantizer chooses its arithmetic once, at build time, so the
15
+ * per-message hot path is a few float operations and no allocation.
16
+ * Three shapes of resolution get three paths:
18
17
  *
19
- * Decimal places follow the natural rule:
20
- * - `resolution = 0.001` → 3 decimal places
21
- * - `resolution = 0.01` → 2 decimal places
22
- * - `resolution = 0.1` → 1 decimal place
23
- * - `resolution = 1` → 0 (returns `null`, signalling passthrough)
24
- * - `resolution = 5` → 0 (whole-number resolution; values still
25
- * snap to multiples of 5, but no decimals to
26
- * format)
18
+ * - The inverse is a whole number (0.1, 0.01, 0.001, 0.25, 0.5, 0.2,
19
+ * 0.125). The value is `Math.round( v * inverse ) / inverse`. A
20
+ * division by an exact whole number is correctly rounded, so it
21
+ * returns the nearest double to the grid value: 101 / 10 is 10.1
22
+ * and 95 / 4 is 23.75. No string is built.
23
+ * - The resolution is a whole number (5, 10, 7). The value is
24
+ * `Math.round( v / resolution ) * resolution`, exact for whole
25
+ * numbers. No string is built.
26
+ * - Anything else (0.3, 0.15, 2.5). The old path stays: multiply,
27
+ * round, multiply, then `toFixed` to trim the noise and `Number` to
28
+ * parse it back. The trim keeps as many decimals as the resolution
29
+ * itself has. This path allocates one short string per value. It is
30
+ * rare: a declared resolution is nearly always a power of ten.
27
31
  *
28
- * The `toFixed` step cleans up floating-point noise — for example,
29
- * `Math.round( 10.1 * 10 ) * 0.1` produces `10.100000000001` because
30
- * of IEEE-754 rounding, and `toFixed(1)` returns the tidy `"10.1"`
31
- * which `Number(...)` parses back to `10.1`.
32
+ * Why not `toFixed` for everything. `Math.round( 10.1 * 10 ) * 0.1` is
33
+ * `10.100000000000001`, because 0.1 is not exact in binary, so a
34
+ * multiply by the resolution needs a trim. The trim built a string per
35
+ * value on the storage hot path. It also cut grid values short: with a
36
+ * resolution of 0.25 the trim kept one decimal, so 23.75 became 23.8,
37
+ * off the grid. Division by the exact inverse has neither problem.
32
38
  *
33
39
  * Returning `null` for `resolution === 1` (or undefined) is a small but
34
40
  * load-bearing optimisation: the common case is "no resolution declared"
@@ -62,9 +68,27 @@ const buildResolutionQuantizer = function ( resolution ) {
62
68
  if ( resolution === undefined || resolution === 1 ) return null;
63
69
 
64
70
  const invResolution = 1 / resolution;
65
- const decimalPlaces = resolution < 1 ?
66
- Math.ceil( -Math.log10( resolution ) ) :
67
- 0;
71
+
72
+ if ( Number.isInteger( invResolution ) ) {
73
+ return function ( v ) {
74
+ return Math.round( v * invResolution ) / invResolution;
75
+ };
76
+ }
77
+
78
+ if ( Number.isInteger( resolution ) ) {
79
+ return function ( v ) {
80
+ return Math.round( v / resolution ) * resolution;
81
+ };
82
+ }
83
+
84
+ // The string path trims the noise to the decimals the grid needs.
85
+ // The count comes from the resolution's own digits, so 0.15 gets
86
+ // two and 2.5 gets one. A resolution below one millionth prints in
87
+ // exponent form, so it takes the count from its magnitude instead.
88
+ const text = String( resolution );
89
+ const decimalPlaces = ( text.indexOf( 'e' ) === -1 ) ?
90
+ ( text.length - text.indexOf( '.' ) - 1 ) :
91
+ Math.ceil( -Math.log10( resolution ) );
68
92
 
69
93
  return function ( v ) {
70
94
  const quantized = Math.round( v * invResolution ) * resolution;
@@ -54,8 +54,10 @@ import { logger } from '../logger/index.js';
54
54
  * contract's method surface.
55
55
  * `getPressure` is optional — its presence decides whether the handle is
56
56
  * collected into `backpressureAwareSinks`, not whether the wire succeeds.
57
+ * `flush` is required, as it is for storages: ADR-018 §6 lists it in
58
+ * the sink floor for both roles.
57
59
  */
58
- const REQUIRED_EMITTER_METHODS = [ 'publishNow', 'shutdown', 'getHealth' ];
60
+ const REQUIRED_EMITTER_METHODS = [ 'publishNow', 'flush', 'shutdown', 'getHealth' ];
59
61
 
60
62
  /**
61
63
  * Serializes a rejection's `dropped` payload for the shutdown log. A
@@ -146,6 +146,13 @@ const storages = ( function () {
146
146
  // to assetClass.name if not explicitly specified.
147
147
  const wire = async function ( specs, storageConfigs, storageModules, assetClass = null ) {
148
148
  const pendingStorages = [];
149
+ // Names whose factory has been called in THIS wire() call. The
150
+ // singleton slot is filled only after every pending factory has
151
+ // settled. Without this set, a flow with several persist-if nodes
152
+ // on one storage would call the factory once per node and keep
153
+ // only the last handle. Each orphan would hold a flush timer, an
154
+ // agent, and a sender for the life of the process.
155
+ const pendingNames = new Set();
149
156
 
150
157
  for ( const spec of specs ) {
151
158
  // Skip non-persistIf nodes
@@ -155,8 +162,8 @@ const storages = ( function () {
155
162
 
156
163
  const storageName = spec.storageName;
157
164
 
158
- // Skip if already in singleton registry
159
- if ( singletons[ storageName ] ) {
165
+ // Skip if already in singleton registry, or already being built
166
+ if ( singletons[ storageName ] || pendingNames.has( storageName ) ) {
160
167
  continue; // eslint-disable-line no-continue
161
168
  }
162
169
 
@@ -196,6 +203,7 @@ const storages = ( function () {
196
203
 
197
204
  // createStorage may return Promise (async) or storage directly (sync)
198
205
  const storageOrPromise = module.createStorage( effectiveConfig );
206
+ pendingNames.add( storageName );
199
207
  pendingStorages.push( { storageName, storageOrPromise } );
200
208
  }
201
209
 
package/src/flow/flow.js CHANGED
@@ -47,6 +47,27 @@ import { validateWithSchema } from '../core/utils/validate/index.js';
47
47
  import { assetClassSchema } from '../core/semantics/schemas/index.js';
48
48
  import { expandTemplateSpec } from './expand-template-spec.js';
49
49
 
50
+ /**
51
+ * Throws the flow's setup error for an adapter config that the
52
+ * adapter's own schema rejected. The error carries `INVALID_CONFIG`,
53
+ * the ADR-018 setup code, and the ADR-028 bracket. A program matches
54
+ * the code; an operator reads the remedy the schema names.
55
+ *
56
+ * @param {{valid: boolean, errors: string[]}} validation - The schema result
57
+ * @param {string} label - `flow.<method>.<adapterId>`, for the message
58
+ * @throws {Error} INVALID_CONFIG when the config is invalid
59
+ */
60
+ const throwIfAdapterConfigInvalid = function ( validation, label ) {
61
+ if ( validation.valid ) {
62
+ return;
63
+ }
64
+ const err = new Error(
65
+ `winkComposer/${label}: validation failed [INVALID_CONFIG]:\n - ${validation.errors.join( '\n - ' )}`
66
+ );
67
+ err.code = 'INVALID_CONFIG';
68
+ throw err;
69
+ }; // throwIfAdapterConfigInvalid()
70
+
50
71
  const flow = function ( flowName = 'flow1' ) {
51
72
  const api = Object.create( null );
52
73
 
@@ -171,7 +192,7 @@ const flow = function ( flowName = 'flow1' ) {
171
192
  // not later inside adapter.start().
172
193
  if ( adapter.configSchema ) {
173
194
  const validation = validateWithSchema( adapter.configSchema, sourceConfig, 'source' );
174
- validation.throwIfInvalid( `flow.source.${adapter.id || 'unknown'}` );
195
+ throwIfAdapterConfigInvalid( validation, `flow.source.${adapter.id || 'unknown'}` );
175
196
  }
176
197
 
177
198
  runtime.source = { adapter, config: sourceConfig };
@@ -195,7 +216,7 @@ const flow = function ( flowName = 'flow1' ) {
195
216
  // not later inside adapter.createEmitter().
196
217
  if ( adapter.configSchema ) {
197
218
  const validation = validateWithSchema( adapter.configSchema, emitterConfig, 'emitter' );
198
- validation.throwIfInvalid( `flow.emitter.${adapter.id}` );
219
+ throwIfAdapterConfigInvalid( validation, `flow.emitter.${adapter.id}` );
199
220
  }
200
221
 
201
222
  runtime.emitters[ adapter.id ] = { adapter, config: emitterConfig };
@@ -219,7 +240,7 @@ const flow = function ( flowName = 'flow1' ) {
219
240
  // not later inside adapter.createStorage().
220
241
  if ( adapter.configSchema ) {
221
242
  const validation = validateWithSchema( adapter.configSchema, storageConfig, 'storage' );
222
- validation.throwIfInvalid( `flow.storage.${adapter.id}` );
243
+ throwIfAdapterConfigInvalid( validation, `flow.storage.${adapter.id}` );
223
244
  }
224
245
 
225
246
  runtime.storages[ adapter.id ] = { adapter, config: storageConfig };
package/src/flow/run.js CHANGED
@@ -272,6 +272,21 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
272
272
  }
273
273
  }; // runDrainStage()
274
274
 
275
+ // A sink stage returns `Promise.allSettled` results. The wiring
276
+ // logs each adapter whose drain rejected and lets its siblings
277
+ // finish, so one failing sink never strands another's data. The
278
+ // loss must still reach this handle's caller. A rejected drain is
279
+ // how the shutdown manager knows to exit 1 (ADR-018 §7). So once
280
+ // every adapter in the stage has drained, the stage throws the
281
+ // first rejected reason, code and dropped count intact.
282
+ const throwFirstRejection = function ( results ) {
283
+ for ( let i = 0; i < results.length; i += 1 ) {
284
+ if ( results[ i ].status === 'rejected' ) {
285
+ throw results[ i ].reason;
286
+ }
287
+ }
288
+ }; // throwFirstRejection()
289
+
275
290
  const drainAll = async function () {
276
291
  const stageErrors = [];
277
292
  try {
@@ -281,9 +296,9 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
281
296
  () => stopSource( { timeout: STAGE_TIMEOUT_MS } ) );
282
297
  }
283
298
  await runDrainStage( 'emitter', stageErrors,
284
- () => emitters.shutdown( { timeout: STAGE_TIMEOUT_MS } ) );
299
+ async () => throwFirstRejection( await emitters.shutdown( { timeout: STAGE_TIMEOUT_MS } ) ) );
285
300
  await runDrainStage( 'storage', stageErrors,
286
- () => storages.shutdown( { timeout: STAGE_TIMEOUT_MS } ) );
301
+ async () => throwFirstRejection( await storages.shutdown( { timeout: STAGE_TIMEOUT_MS } ) ) );
287
302
  } finally {
288
303
  // Whatever the path here — clean drain or a stage that threw —
289
304
  // the source has stopped producing, so unblock any
@@ -311,8 +326,15 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
311
326
  // the process as unhandled. The classified log keeps the loss
312
327
  // loud without the crash, and a caller that later awaits
313
328
  // `handle.shutdown()` still receives the memoized rejection.
329
+ //
330
+ // The exit code carries the loss too. This drain has no awaiting
331
+ // caller, and the flow left the shutdown manager's list when the
332
+ // drain began, so no other path can set it. A batch script that
333
+ // replays a file and loses rows at the end must exit 1, the same
334
+ // as the signal path does (ADR-018 §7).
314
335
  const observedShutdown = function () {
315
336
  shutdown().catch( function ( err ) {
337
+ process.exitCode = 1;
316
338
  logger.error(
317
339
  `winkComposer/flow '${flowName}': shutdown failed ` +
318
340
  `[${( err && err.code ) || 'UNKNOWN'}]: ${( err && err.message ) || String( err )}`