@winkjs/composer 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/composer.js +1 -1
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +103 -658
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/index.js +16 -1
- package/src/core/env-vars.js +99 -13
- package/src/core/logger/index.js +1 -1
- package/src/core/source-manager/mqtt/client.js +197 -63
- package/src/core/source-manager/mqtt/dedup.js +16 -11
- package/src/core/source-manager/mqtt/index.js +32 -5
- package/src/core/source-manager/mqtt/status.js +168 -56
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +574 -847
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +403 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +23 -2
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/wiring/wire-emitters.js +3 -1
- package/src/core/wiring/wire-storages.js +10 -2
- package/src/flow/flow.js +24 -3
- package/src/flow/run.js +24 -2
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 )}`
|