@yarkivaev/scada 2.4.4 → 2.4.6
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 +17 -4
- package/src/infrastructure/ingest/pipelines/stompOptions.js +3 -1
- package/src/infrastructure/streams/follow.js +4 -2
- package/src/infrastructure/streams/stompBody.js +2 -1
- package/src/infrastructure/streams/stompLifecycle.js +46 -0
- package/src/infrastructure/streams/stompSource.js +23 -16
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. Machine lanes run in parallel;
|
|
47
|
+
* only storage execution consumes its watchdog, not bounded lane queue wait.
|
|
47
48
|
*
|
|
48
49
|
* @param {string} stomp - STOMP broker URL
|
|
49
50
|
* @param {string} postgres - PostgreSQL connection URL
|
|
@@ -56,17 +57,29 @@ 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
|
+
// Each admitted frame can flush one heartbeat and then persist itself; prefetch bounds queue depth.
|
|
75
|
+
const deliveryTimeoutMs = Math.min(2147483647, (config.acceptTimeoutMs ?? 10000) * (config.prefetch ?? 32) * 2);
|
|
76
|
+
source = stompSource(stomp, destination, coalesce,
|
|
77
|
+
stompOptions(config, { serial: false, manualAck: true, deliveryTimeoutMs }));
|
|
66
78
|
const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
|
|
67
79
|
intervalMs: (config.poll || 5) * 1000
|
|
68
80
|
});
|
|
69
81
|
return {
|
|
82
|
+
health() { return source.health(); },
|
|
70
83
|
destination,
|
|
71
84
|
async start() {
|
|
72
85
|
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, deliveryTimeoutMs: config.deliveryTimeoutMs, 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
|
});
|
|
@@ -3,8 +3,9 @@ import budgetAccept from './budgetAccept.js';
|
|
|
3
3
|
|
|
4
4
|
export function stompSettings(options, prefetch) {
|
|
5
5
|
const config = { acceptTimeoutMs: 10000, stopTimeoutMs: 5000, maxMessageBytes: 1048576, ...options };
|
|
6
|
+
config.deliveryTimeoutMs ??= config.acceptTimeoutMs;
|
|
6
7
|
config.maxPendingBytes ??= prefetch * config.maxMessageBytes;
|
|
7
|
-
['acceptTimeoutMs', 'stopTimeoutMs', 'maxMessageBytes', 'maxPendingBytes'].forEach((key) => { positive(config[key], key); });
|
|
8
|
+
['acceptTimeoutMs', 'deliveryTimeoutMs', 'stopTimeoutMs', 'maxMessageBytes', 'maxPendingBytes'].forEach((key) => { positive(config[key], key); });
|
|
8
9
|
if (!Number.isSafeInteger(config.maxMessageBytes) || !Number.isSafeInteger(config.maxPendingBytes)) {
|
|
9
10
|
throw new TypeError('STOMP byte capacities must be safe integers');
|
|
10
11
|
}
|
|
@@ -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
|
|
|
@@ -84,8 +85,8 @@ async function deliver(ctx, body) {
|
|
|
84
85
|
try {
|
|
85
86
|
await deadline(Promise.resolve().then(() => {
|
|
86
87
|
return acceptStomp(ctx, body, settle);
|
|
87
|
-
}), ctx.lifecycle.
|
|
88
|
-
ctx.lifecycle.
|
|
88
|
+
}), ctx.lifecycle.deliveryTimeoutMs, 'STOMP acceptance', (error) => {
|
|
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
|
|
|
@@ -141,6 +139,8 @@ function bind(channel, destination, collector, delivery) {
|
|
|
141
139
|
* Messages are `{destination, payload, settle}`. Default ack-after-accept;
|
|
142
140
|
* with `manualAck` the collector owns settle. Arrivals enqueue in
|
|
143
141
|
* subscribe order. With `serial: false` persist may overlap after enqueue.
|
|
142
|
+
* deliveryTimeoutMs optionally includes bounded collector queue wait; body reads
|
|
143
|
+
* retain acceptTimeoutMs. Collectors owning an execution deadline can separate them.
|
|
144
144
|
* A finite prefetch (default 32) bounds unacknowledged broker deliveries.
|
|
145
145
|
*
|
|
146
146
|
* @param {string} url - STOMP broker URL
|
|
@@ -150,6 +150,12 @@ function bind(channel, destination, collector, delivery) {
|
|
|
150
150
|
* @param {number} [options.prefetch=32] - Positive integer delivery window
|
|
151
151
|
* @returns {object} Source with start() and stop() methods
|
|
152
152
|
*/
|
|
153
|
+
function watchConnection(failover, lifecycle) {
|
|
154
|
+
failover.on('connect', () => { lifecycle.transition('running'); });
|
|
155
|
+
failover.on('connecting', () => { lifecycle.transition(lifecycle.connected ? 'degraded' : 'starting'); });
|
|
156
|
+
failover.on('error', (error) => { lifecycle.transition('degraded'); lifecycle.report(error); });
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
export default function stompSource(url, destination, collector, options = {}) {
|
|
154
160
|
if (typeof url !== 'string' || url.length === 0) {
|
|
155
161
|
throw new Error('URL must be a non-empty string');
|
|
@@ -172,6 +178,7 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
172
178
|
return {
|
|
173
179
|
start() {
|
|
174
180
|
if (state.subscribed()) {
|
|
181
|
+
if (state.delivery.fatal) { throw state.delivery.fatal; }
|
|
175
182
|
return;
|
|
176
183
|
}
|
|
177
184
|
const failover = new stompit.ConnectFailover([serverConfig(parsed, options)], {
|
|
@@ -180,18 +187,18 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
180
187
|
maxReconnects: -1
|
|
181
188
|
});
|
|
182
189
|
const channel = new stompit.Channel(failover);
|
|
183
|
-
const lifecycle =
|
|
190
|
+
const lifecycle = stompLifecycle(settings, channel, destination);
|
|
191
|
+
watchConnection(failover, lifecycle);
|
|
184
192
|
bind(channel, destination, collector, { serial, manualAck, prefetch, lifecycle });
|
|
185
193
|
state = subscribed(channel, lifecycle);
|
|
186
194
|
},
|
|
195
|
+
// Asynchronous downstream work uses the same fence as delivery deadlines.
|
|
196
|
+
fail(error) { if (state.subscribed()) { state.delivery.fail(error); } },
|
|
197
|
+
health() { return state.subscribed() ? state.delivery.health() : { state: 'idle', reason: null }; },
|
|
187
198
|
stop() {
|
|
188
|
-
if (!state.subscribed()) {
|
|
189
|
-
return Promise.resolve();
|
|
190
|
-
}
|
|
199
|
+
if (!state.subscribed()) { return Promise.resolve(); }
|
|
191
200
|
const stopped = state;
|
|
192
|
-
stopped.delivery.
|
|
193
|
-
stopped.delivery.controller.abort(new Error('STOMP source stopped'));
|
|
194
|
-
stopped.channel().close();
|
|
201
|
+
stopped.delivery.halt();
|
|
195
202
|
return deadline(Promise.allSettled([stopped.delivery.chain, ...stopped.delivery.pending]), options.stopTimeoutMs ?? 5000, 'STOMP stop').then(() => {
|
|
196
203
|
if (stopped.delivery.fatal) { throw stopped.delivery.fatal; }
|
|
197
204
|
if (state === stopped) { state = idle(); }
|