@yarkivaev/scada 2.4.4 → 2.4.5
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/package.json +1 -1
- package/src/application/ingestHealth.js +17 -0
- package/src/application/runSupervisorSink.js +16 -0
- package/src/application/runtime/additionalAdapters.js +18 -6
- package/src/application/runtime/taskContext.js +1 -0
- package/src/application/supervisorSink.js +17 -3
- package/src/bin/supervisor-sink.js +7 -23
- package/src/infrastructure/http/edge/edgeApi.js +2 -0
- package/src/infrastructure/http/edge/routes/ingestHealthRoutes.js +10 -0
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +1 -0
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +1 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +14 -4
- package/src/infrastructure/ingest/pipelines/stompOptions.js +3 -1
- package/src/infrastructure/streams/follow.js +4 -2
- package/src/infrastructure/streams/stompLifecycle.js +46 -0
- package/src/infrastructure/streams/stompSource.js +20 -15
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Process health follows all owned readers, not merely the HTTP listener. */
|
|
2
|
+
export default function ingestHealth({ onFatal } = {}) {
|
|
3
|
+
const state = { sources: [], started: false, fatal: null };
|
|
4
|
+
return {
|
|
5
|
+
track(sources) { state.sources = sources; },
|
|
6
|
+
started() { state.started = true; },
|
|
7
|
+
fail(error) {
|
|
8
|
+
if (state.fatal) { return; }
|
|
9
|
+
state.fatal = error;
|
|
10
|
+
onFatal?.(error);
|
|
11
|
+
},
|
|
12
|
+
status() {
|
|
13
|
+
return { live: !state.fatal, ready: state.started && !state.fatal
|
|
14
|
+
&& state.sources.every((source) => {return source.health().state === 'running'}) };
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import supervisorSink from './supervisorSink.js';
|
|
2
|
+
import runRetention from '../infrastructure/ingest/db/runRetention.js';
|
|
3
|
+
import edgeApi from '../infrastructure/http/edge/edgeApi.js';
|
|
4
|
+
|
|
5
|
+
/** A terminal reader error rejects the process lifetime, not only one detached task. */
|
|
6
|
+
export default async function runSupervisorSink(env, { sinkFactory = supervisorSink, apiFactory = edgeApi } = {}) {
|
|
7
|
+
let fail;
|
|
8
|
+
const failed = new Promise((_resolve, reject) => { fail = reject; });
|
|
9
|
+
const sink = sinkFactory(env, { onFatal: fail });
|
|
10
|
+
const api = apiFactory(sink.dataAccess, {
|
|
11
|
+
port: sink.apiPort, token: sink.apiToken, metricsEnabled: sink.metricsEnabled,
|
|
12
|
+
retentionEnabled: sink.retentionEnabled, retentionDays: sink.retentionDays,
|
|
13
|
+
requestTimeoutMs: sink.requestTimeoutMs, pool: sink.pool, runRetention, health: sink.health
|
|
14
|
+
});
|
|
15
|
+
await Promise.all([sink.run(api), failed]);
|
|
16
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ingestHealth from '../ingestHealth.js';
|
|
1
2
|
import { clickhouseSink } from '../../infrastructure/streams/index.js';
|
|
2
3
|
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
3
4
|
import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
|
|
@@ -70,12 +71,23 @@ function silent(spec, pool, context) {
|
|
|
70
71
|
}
|
|
71
72
|
function stomp(spec, pool, context) {
|
|
72
73
|
const shared = borrowed(pool);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const health = ingestHealth({ onFatal(error) {
|
|
75
|
+
context.report?.({ state: 'failed', fatal: true, reason: error.code ?? error.name });
|
|
76
|
+
} });
|
|
77
|
+
function report() {
|
|
78
|
+
const status = health.status();
|
|
79
|
+
let state = status.ready ? 'running' : 'degraded';
|
|
80
|
+
if (!status.live) { state = 'failed'; }
|
|
81
|
+
context.report?.({ state, fatal: !status.live });
|
|
82
|
+
}
|
|
83
|
+
const config = { ...spec.config, ...boundedOptions({ ...spec, options: spec.config }), budget: context.budget,
|
|
84
|
+
onFatal(error) { health.fail(error); }, onState: report };
|
|
85
|
+
const items = [segmentPipeline(spec.stomp, spec.postgres, shared, config),
|
|
86
|
+
decisionPipeline(spec.stomp, shared, config), alertPipeline(spec.stomp, shared, config)];
|
|
87
|
+
health.track(items);
|
|
88
|
+
const group = together(items);
|
|
89
|
+
return { async start() { await group.start(); health.started(); report(); },
|
|
90
|
+
stop() { return group.stop(); } };
|
|
79
91
|
}
|
|
80
92
|
function close(state) {
|
|
81
93
|
state.closing ??= Promise.resolve().then(async () => {
|
|
@@ -9,6 +9,7 @@ export default function taskContext(slot, request, ctx) {
|
|
|
9
9
|
report(event) {
|
|
10
10
|
if (controller.signal.aborted) { return; }
|
|
11
11
|
slot.health = event.state;
|
|
12
|
+
if (event.fatal === true) { ctx.fatal = true; }
|
|
12
13
|
if (event.delivered) { slot.lastDelivery = Date.now(); }
|
|
13
14
|
try { ctx.services.report?.({ taskId: slot.taskId, generation: slot.generation, ...event }); } catch {
|
|
14
15
|
// Observability cannot interrupt collection.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ingestHealth from './ingestHealth.js';
|
|
1
2
|
import segmentPipeline, {
|
|
2
3
|
segmentsIngestDestination
|
|
3
4
|
} from '../infrastructure/ingest/pipelines/segmentPipeline.js';
|
|
@@ -21,6 +22,12 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
|
21
22
|
* @param {NodeJS.ProcessEnv} env - process environment
|
|
22
23
|
* @returns {object} deployment configuration
|
|
23
24
|
*/
|
|
25
|
+
function milliseconds(env, key, fallback) {
|
|
26
|
+
const value = Number(env[key] ?? fallback);
|
|
27
|
+
if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`${key} must be a positive integer`); }
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export function readDeploymentConfig(env) {
|
|
25
32
|
const root = path.join(appRoot, '../..');
|
|
26
33
|
const prefetch = Number(env.STOMP_PREFETCH || '128');
|
|
@@ -44,6 +51,8 @@ export function readDeploymentConfig(env) {
|
|
|
44
51
|
pipelines: env.COLLECTOR_MODE !== 'external' && env.SINK_PIPELINES !== 'false',
|
|
45
52
|
pipeline: {
|
|
46
53
|
prefetch,
|
|
54
|
+
acceptTimeoutMs: milliseconds(env, 'STOMP_ACCEPT_TIMEOUT_MS', 10000),
|
|
55
|
+
stopTimeoutMs: milliseconds(env, 'STOMP_STOP_TIMEOUT_MS', 5000),
|
|
47
56
|
deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
|
|
48
57
|
size: parseInt(env.BATCH_SIZE || '50', 10),
|
|
49
58
|
interval: parseInt(env.FLUSH_INTERVAL || '2', 10),
|
|
@@ -60,9 +69,10 @@ export function readDeploymentConfig(env) {
|
|
|
60
69
|
};
|
|
61
70
|
}
|
|
62
71
|
|
|
63
|
-
function startPipelines(cfg, pool) {
|
|
72
|
+
function startPipelines(cfg, pool, health) {
|
|
64
73
|
const pipelines = [segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline),
|
|
65
74
|
decisionPipeline(cfg.stomp, pool, cfg.pipeline), alertPipeline(cfg.stomp, pool, cfg.pipeline)];
|
|
75
|
+
health.track(pipelines);
|
|
66
76
|
return Promise.all(pipelines.map((pipeline) => { return pipeline.start(); }));
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -73,12 +83,15 @@ function startPipelines(cfg, pool) {
|
|
|
73
83
|
* @param {NodeJS.ProcessEnv} [env] - process environment, defaults to process.env
|
|
74
84
|
* @returns {object} sink runtime with pool, dataAccess, and run(httpServer?)
|
|
75
85
|
*/
|
|
76
|
-
export default function supervisorSink(env = process.env) {
|
|
86
|
+
export default function supervisorSink(env = process.env, options = {}) {
|
|
77
87
|
const cfg = readDeploymentConfig(env);
|
|
88
|
+
const health = ingestHealth(options);
|
|
89
|
+
cfg.pipeline.onFatal = (error) => { health.fail(error); };
|
|
78
90
|
const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
|
|
79
91
|
const metricsEnabled = cfg.sinkDbProfile === 'edge';
|
|
80
92
|
const dataAccess = stateDataFromPool(pool, { metricsEnabled });
|
|
81
93
|
return {
|
|
94
|
+
health: () => {return health.status()},
|
|
82
95
|
apiPort: cfg.apiPort,
|
|
83
96
|
apiToken: cfg.apiToken,
|
|
84
97
|
sinkDbProfile: cfg.sinkDbProfile,
|
|
@@ -100,8 +113,9 @@ export default function supervisorSink(env = process.env) {
|
|
|
100
113
|
await adminPool.end();
|
|
101
114
|
}
|
|
102
115
|
if (cfg.pipelines) {
|
|
103
|
-
await startPipelines(cfg, pool);
|
|
116
|
+
await startPipelines(cfg, pool, health);
|
|
104
117
|
}
|
|
118
|
+
health.started();
|
|
105
119
|
if (httpServer) {
|
|
106
120
|
httpServer.start();
|
|
107
121
|
}
|
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*/
|
|
4
|
-
process.on('unhandledRejection', (err) => {
|
|
5
|
-
process.stderr.write(`Unhandled rejection: ${String(err)}\n`);
|
|
6
|
-
process.exit(1);
|
|
7
|
-
});
|
|
1
|
+
/** Supervisor-sink process; a fenced reader must not leave a healthy zombie HTTP server. */
|
|
2
|
+
import runSupervisorSink from '../application/runSupervisorSink.js';
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const sink = supervisorSink(process.env);
|
|
14
|
-
const http = edgeApi(sink.dataAccess, {
|
|
15
|
-
port: sink.apiPort,
|
|
16
|
-
token: sink.apiToken,
|
|
17
|
-
metricsEnabled: sink.metricsEnabled,
|
|
18
|
-
retentionEnabled: sink.retentionEnabled,
|
|
19
|
-
retentionDays: sink.retentionDays,
|
|
20
|
-
requestTimeoutMs: sink.requestTimeoutMs,
|
|
21
|
-
pool: sink.pool,
|
|
22
|
-
runRetention
|
|
4
|
+
process.on('unhandledRejection', (error) => {
|
|
5
|
+
process.stderr.write(`Unhandled rejection: ${String(error)}\n`);
|
|
6
|
+
process.exit(1);
|
|
23
7
|
});
|
|
24
8
|
|
|
25
|
-
|
|
26
|
-
process.stderr.write(
|
|
9
|
+
runSupervisorSink(process.env).catch((error) => {
|
|
10
|
+
process.stderr.write(`Supervisor sink failed: ${String(error)}\n`);
|
|
27
11
|
process.exit(1);
|
|
28
12
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
+
import ingestHealthRoutes from './routes/ingestHealthRoutes.js';
|
|
2
3
|
import checkpointRoutes from './routes/checkpoint/checkpointRoutes.js';
|
|
3
4
|
import metricsRoutes from './routes/metrics/metricsRoutes.js';
|
|
4
5
|
import retentionRoutes from './routes/retention/retentionRoutes.js';
|
|
@@ -25,6 +26,7 @@ export function createEdgeApi(dataAccess, options) {
|
|
|
25
26
|
? metricsRoutes(token, metrics)
|
|
26
27
|
: [];
|
|
27
28
|
return routes([
|
|
29
|
+
...(opt.health ? ingestHealthRoutes(opt.health) : []),
|
|
28
30
|
...admin,
|
|
29
31
|
...checkpointRoutes(token, checkpoints),
|
|
30
32
|
...metricRoutes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { route } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/** Health never exposes credentials, messages or application records. */
|
|
4
|
+
export default function ingestHealthRoutes(health) {
|
|
5
|
+
return [['/ready', 'ready'], ['/live', 'live']].map(([path, key]) => {return route('GET', path, (_req, res) => {
|
|
6
|
+
const available = health()[key] === true;
|
|
7
|
+
res.writeHead(available ? 200 : 503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
8
|
+
res.end(JSON.stringify({ [key]: available }));
|
|
9
|
+
})});
|
|
10
|
+
}
|
|
@@ -29,6 +29,7 @@ export default function decisionPipeline(stomp, pool, config) {
|
|
|
29
29
|
const source = stompSource(stomp, '/exchange/scada.user_decisions', codec,
|
|
30
30
|
stompOptions(config));
|
|
31
31
|
return {
|
|
32
|
+
health() { return source.health(); },
|
|
32
33
|
/**
|
|
33
34
|
* Starts consuming user decisions from RabbitMQ.
|
|
34
35
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import stompOptions from './stompOptions.js';
|
|
2
|
+
import deadline from '../../streams/deadline.js';
|
|
2
3
|
import { postgresSink, stompSource } from '../../streams/index.js';
|
|
3
4
|
import segmentDispatch from '../../../domain/segment/dispatch.js';
|
|
4
5
|
import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
|
|
@@ -42,8 +43,8 @@ function segmentSinks(postgres, pool) {
|
|
|
42
43
|
/**
|
|
43
44
|
* Pipeline for streaming STOMP segment data to PostgreSQL segments table.
|
|
44
45
|
* Subscribes to the durable ingest queue so shovel traffic survives consumer gaps.
|
|
45
|
-
* Pending heartbeats are coalesced per machine
|
|
46
|
-
*
|
|
46
|
+
* Pending heartbeats are coalesced per machine. Source acceptance is FIFO:
|
|
47
|
+
* queued lane work must not consume another delivery's execution deadline.
|
|
47
48
|
*
|
|
48
49
|
* @param {string} stomp - STOMP broker URL
|
|
49
50
|
* @param {string} postgres - PostgreSQL connection URL
|
|
@@ -56,17 +57,26 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
|
|
|
56
57
|
const { segmentSink, splitSink } = segmentSinks(postgres, pool);
|
|
57
58
|
const continued = continuedSegments(segmentSink, pool, budget);
|
|
58
59
|
const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
|
|
59
|
-
const
|
|
60
|
+
// eslint-disable-next-line prefer-const -- The timer codec closes over the source constructed with that codec.
|
|
61
|
+
let source;
|
|
62
|
+
const codec = segmentCodec(dispatch);
|
|
63
|
+
const bounded = { accept(envelope) {
|
|
64
|
+
return deadline(Promise.resolve().then(() => { return codec.accept(envelope); }),
|
|
65
|
+
config.acceptTimeoutMs ?? 10000, 'STOMP acceptance', (error) => { source.fail(error); });
|
|
66
|
+
} };
|
|
67
|
+
// Heartbeat timer flushes outlive source.accept and need their own execution deadline.
|
|
68
|
+
const coalesce = segmentCoalesce(bounded, {
|
|
60
69
|
size: config.size,
|
|
61
70
|
deadLetter: config.deadLetter,
|
|
62
71
|
interval: config.interval
|
|
63
72
|
});
|
|
64
73
|
const destination = config.segmentsDestination || segmentsIngestDestination;
|
|
65
|
-
|
|
74
|
+
source = stompSource(stomp, destination, coalesce, stompOptions(config, { serial: true, manualAck: true }));
|
|
66
75
|
const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
|
|
67
76
|
intervalMs: (config.poll || 5) * 1000
|
|
68
77
|
});
|
|
69
78
|
return {
|
|
79
|
+
health() { return source.health(); },
|
|
70
80
|
destination,
|
|
71
81
|
async start() {
|
|
72
82
|
const repair = abutSegments(pool, budget);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export default function stompOptions(config, overrides = {}) {
|
|
3
3
|
const options = { login: config.login, passcode: config.passcode, host: config.host,
|
|
4
4
|
prefetch: config.prefetch, maxMessageBytes: config.maxMessageBytes,
|
|
5
|
-
maxPendingBytes: config.maxPendingBytes, budget: config.budget,
|
|
5
|
+
maxPendingBytes: config.maxPendingBytes, budget: config.budget,
|
|
6
|
+
acceptTimeoutMs: config.acceptTimeoutMs, stopTimeoutMs: config.stopTimeoutMs,
|
|
7
|
+
onError: config.onError, onFatal: config.onFatal, onState: config.onState, ...overrides };
|
|
6
8
|
return Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
|
|
7
9
|
}
|
|
@@ -26,7 +26,7 @@ function resume(pending) {
|
|
|
26
26
|
* let pending = Promise.resolve();
|
|
27
27
|
* pending = follow(pending, () => Promise.resolve('a'), write, false);
|
|
28
28
|
*/
|
|
29
|
-
export default function follow(pending, read, deliver, serial) {
|
|
29
|
+
export default function follow(pending, read, deliver, serial, onError = (error) => { process.stderr.write(`Delivery failed: ${error.message}\n`); }) {
|
|
30
30
|
return resume(pending).then(async () => {
|
|
31
31
|
const body = await read();
|
|
32
32
|
const run = deliver(body);
|
|
@@ -34,7 +34,9 @@ export default function follow(pending, read, deliver, serial) {
|
|
|
34
34
|
return run;
|
|
35
35
|
}
|
|
36
36
|
void run.catch((error) => {
|
|
37
|
-
|
|
37
|
+
try { onError(error); } catch {
|
|
38
|
+
// Reporting cannot turn a detached delivery into an unhandled rejection.
|
|
39
|
+
}
|
|
38
40
|
});
|
|
39
41
|
return undefined;
|
|
40
42
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** A fenced subscription reports failure once and cannot be revived by late callbacks. */
|
|
2
|
+
export default function stompLifecycle(settings, channel, destination) {
|
|
3
|
+
const state = { ...settings, active: true, fatal: null, phase: 'starting', connected: false,
|
|
4
|
+
controller: new AbortController(), pending: new Set(), bytes: 0, closed: false };
|
|
5
|
+
function notify(callback, value) {
|
|
6
|
+
try { callback?.(value); } catch {
|
|
7
|
+
// An observer cannot interrupt transport fencing or broker callbacks.
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function close() {
|
|
11
|
+
if (!state.closed) { state.closed = true; channel.close(); }
|
|
12
|
+
}
|
|
13
|
+
function report(error) {
|
|
14
|
+
if (settings.onError) { notify(settings.onError, error); }
|
|
15
|
+
else { process.stderr.write(`STOMP source ${destination}: ${error.message}\n`); }
|
|
16
|
+
}
|
|
17
|
+
return Object.assign(state, {
|
|
18
|
+
transition(phase) {
|
|
19
|
+
if (!state.active || state.fatal) { return; }
|
|
20
|
+
state.phase = phase;
|
|
21
|
+
if (phase === 'running') { state.connected = true; }
|
|
22
|
+
notify(settings.onState, { state: phase });
|
|
23
|
+
},
|
|
24
|
+
fail(error) {
|
|
25
|
+
if (!state.active || state.fatal) { return; }
|
|
26
|
+
state.fatal = error;
|
|
27
|
+
state.active = false;
|
|
28
|
+
state.phase = 'failed';
|
|
29
|
+
state.controller.abort(error);
|
|
30
|
+
close();
|
|
31
|
+
report(error);
|
|
32
|
+
notify(settings.onState, { state: 'failed', reason: error.message });
|
|
33
|
+
notify(settings.onFatal, error);
|
|
34
|
+
},
|
|
35
|
+
report(error) {
|
|
36
|
+
if (state.active && error !== state.fatal) { report(error); }
|
|
37
|
+
},
|
|
38
|
+
halt() {
|
|
39
|
+
state.active = false;
|
|
40
|
+
state.controller.abort(new Error('STOMP source stopped'));
|
|
41
|
+
if (!state.fatal) { state.phase = 'stopped'; }
|
|
42
|
+
close();
|
|
43
|
+
},
|
|
44
|
+
health() { return { state: state.phase, reason: state.fatal?.message ?? null }; }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import stompit from 'stompit';
|
|
2
2
|
import follow from './follow.js';
|
|
3
|
+
import stompLifecycle from './stompLifecycle.js';
|
|
3
4
|
import deadline from './deadline.js';
|
|
4
5
|
import { acceptStomp, readStomp, stompSettings } from './stompBody.js';
|
|
5
6
|
|
|
@@ -85,7 +86,7 @@ async function deliver(ctx, body) {
|
|
|
85
86
|
await deadline(Promise.resolve().then(() => {
|
|
86
87
|
return acceptStomp(ctx, body, settle);
|
|
87
88
|
}), ctx.lifecycle.acceptTimeoutMs, 'STOMP acceptance', (error) => {
|
|
88
|
-
ctx.lifecycle.
|
|
89
|
+
ctx.lifecycle.fail(error);
|
|
89
90
|
});
|
|
90
91
|
if (!ctx.manualAck) {
|
|
91
92
|
settle.ack();
|
|
@@ -113,25 +114,22 @@ function bind(channel, destination, collector, delivery) {
|
|
|
113
114
|
let pending = Promise.resolve();
|
|
114
115
|
channel.subscribe({ destination, ack: 'client-individual', 'prefetch-count': String(prefetch) }, (err, message) => {
|
|
115
116
|
if (err) {
|
|
117
|
+
lifecycle.fail(err);
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
const ctx = { channel, message, destination, collector, manualAck, lifecycle };
|
|
119
121
|
pending = follow(pending, () => {
|
|
120
122
|
if (!lifecycle.active) { return Promise.reject(new Error('STOMP source stopped')); }
|
|
121
123
|
return deadline(readStomp(message, lifecycle.maxMessageBytes), lifecycle.acceptTimeoutMs, 'STOMP read').catch((error) => {
|
|
122
|
-
lifecycle.
|
|
124
|
+
lifecycle.fail(error); throw error;
|
|
123
125
|
});
|
|
124
126
|
}, (body) => {
|
|
125
127
|
const task = deliver(ctx, body);
|
|
126
128
|
lifecycle.pending.add(task);
|
|
127
129
|
return task.finally(() => { lifecycle.pending.delete(task); });
|
|
128
|
-
}, serial);
|
|
130
|
+
}, serial, (error) => { lifecycle.report(error); });
|
|
129
131
|
lifecycle.chain = pending;
|
|
130
|
-
void pending.catch((error) => {
|
|
131
|
-
try { lifecycle.onError?.(error); } catch {
|
|
132
|
-
// Reporting cannot reject a transport callback.
|
|
133
|
-
}
|
|
134
|
-
});
|
|
132
|
+
void pending.catch((error) => { lifecycle.report(error); });
|
|
135
133
|
});
|
|
136
134
|
}
|
|
137
135
|
|
|
@@ -150,6 +148,12 @@ function bind(channel, destination, collector, delivery) {
|
|
|
150
148
|
* @param {number} [options.prefetch=32] - Positive integer delivery window
|
|
151
149
|
* @returns {object} Source with start() and stop() methods
|
|
152
150
|
*/
|
|
151
|
+
function watchConnection(failover, lifecycle) {
|
|
152
|
+
failover.on('connect', () => { lifecycle.transition('running'); });
|
|
153
|
+
failover.on('connecting', () => { lifecycle.transition(lifecycle.connected ? 'degraded' : 'starting'); });
|
|
154
|
+
failover.on('error', (error) => { lifecycle.transition('degraded'); lifecycle.report(error); });
|
|
155
|
+
}
|
|
156
|
+
|
|
153
157
|
export default function stompSource(url, destination, collector, options = {}) {
|
|
154
158
|
if (typeof url !== 'string' || url.length === 0) {
|
|
155
159
|
throw new Error('URL must be a non-empty string');
|
|
@@ -172,6 +176,7 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
172
176
|
return {
|
|
173
177
|
start() {
|
|
174
178
|
if (state.subscribed()) {
|
|
179
|
+
if (state.delivery.fatal) { throw state.delivery.fatal; }
|
|
175
180
|
return;
|
|
176
181
|
}
|
|
177
182
|
const failover = new stompit.ConnectFailover([serverConfig(parsed, options)], {
|
|
@@ -180,18 +185,18 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
180
185
|
maxReconnects: -1
|
|
181
186
|
});
|
|
182
187
|
const channel = new stompit.Channel(failover);
|
|
183
|
-
const lifecycle =
|
|
188
|
+
const lifecycle = stompLifecycle(settings, channel, destination);
|
|
189
|
+
watchConnection(failover, lifecycle);
|
|
184
190
|
bind(channel, destination, collector, { serial, manualAck, prefetch, lifecycle });
|
|
185
191
|
state = subscribed(channel, lifecycle);
|
|
186
192
|
},
|
|
193
|
+
// Asynchronous downstream work uses the same fence as delivery deadlines.
|
|
194
|
+
fail(error) { if (state.subscribed()) { state.delivery.fail(error); } },
|
|
195
|
+
health() { return state.subscribed() ? state.delivery.health() : { state: 'idle', reason: null }; },
|
|
187
196
|
stop() {
|
|
188
|
-
if (!state.subscribed()) {
|
|
189
|
-
return Promise.resolve();
|
|
190
|
-
}
|
|
197
|
+
if (!state.subscribed()) { return Promise.resolve(); }
|
|
191
198
|
const stopped = state;
|
|
192
|
-
stopped.delivery.
|
|
193
|
-
stopped.delivery.controller.abort(new Error('STOMP source stopped'));
|
|
194
|
-
stopped.channel().close();
|
|
199
|
+
stopped.delivery.halt();
|
|
195
200
|
return deadline(Promise.allSettled([stopped.delivery.chain, ...stopped.delivery.pending]), options.stopTimeoutMs ?? 5000, 'STOMP stop').then(() => {
|
|
196
201
|
if (stopped.delivery.fatal) { throw stopped.delivery.fatal; }
|
|
197
202
|
if (state === stopped) { state = idle(); }
|