@yarkivaev/scada 2.3.115 → 2.4.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 +158 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +14 -5
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +90 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +70 -0
- package/src/infrastructure/opcua/source.js +38 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import { clock } from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { clock, intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
|
|
3
|
+
function track(state, pending) {
|
|
4
|
+
if (!pending || typeof pending.then !== 'function') { return; }
|
|
5
|
+
const task = pending.catch((error) => {
|
|
6
|
+
state.failure = error;
|
|
7
|
+
try { state.onError?.(error); } catch {
|
|
8
|
+
// Reporting must not turn an observed source failure into an unhandled rejection.
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
.finally(() => { state.pending.delete(task); });
|
|
12
|
+
state.pending.add(task);
|
|
13
|
+
}
|
|
14
|
+
async function drained(state) {
|
|
15
|
+
await Promise.all(state.pending);
|
|
16
|
+
if (state.failure) { throw state.failure; }
|
|
17
|
+
}
|
|
3
18
|
/**
|
|
4
19
|
* Whether a stream is past the silence budget.
|
|
5
20
|
*
|
|
@@ -27,8 +42,12 @@ function stopOne(state, name) {
|
|
|
27
42
|
if (!state.live.has(name)) {
|
|
28
43
|
return;
|
|
29
44
|
}
|
|
30
|
-
state.
|
|
31
|
-
state.
|
|
45
|
+
if (state.closing.has(name)) { return; }
|
|
46
|
+
const pending = state.held.get(name).stop();
|
|
47
|
+
if (pending && typeof pending.then === 'function') {
|
|
48
|
+
state.closing.add(name);
|
|
49
|
+
track(state, pending.finally(() => { state.live.delete(name); state.closing.delete(name); }));
|
|
50
|
+
} else { state.live.delete(name); }
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
/**
|
|
@@ -47,7 +66,7 @@ function startOne(state, source) {
|
|
|
47
66
|
poll = source.open(state.collector, state.clk, state.intervalSec);
|
|
48
67
|
state.held.set(name, poll);
|
|
49
68
|
}
|
|
50
|
-
poll.start();
|
|
69
|
+
track(state, poll.start());
|
|
51
70
|
state.live.add(name);
|
|
52
71
|
}
|
|
53
72
|
|
|
@@ -83,15 +102,16 @@ function gateState(config) {
|
|
|
83
102
|
intervalSec: interval,
|
|
84
103
|
sources,
|
|
85
104
|
clk: config.clock || clock(),
|
|
86
|
-
delay: config.delay ||
|
|
87
|
-
|
|
88
|
-
}),
|
|
89
|
-
clear: config.clear || clearInterval,
|
|
105
|
+
delay: config.delay || setTimeout,
|
|
106
|
+
clear: config.clear || clearTimeout,
|
|
90
107
|
lastSeen: new Map(),
|
|
91
108
|
held: new Map(),
|
|
92
109
|
live: new Set(),
|
|
110
|
+
closing: new Set(),
|
|
111
|
+
pending: new Set(),
|
|
112
|
+
failure: null,
|
|
113
|
+
onError: config.onError,
|
|
93
114
|
collector: undefined,
|
|
94
|
-
timer: undefined,
|
|
95
115
|
running: false
|
|
96
116
|
};
|
|
97
117
|
}
|
|
@@ -119,6 +139,9 @@ export default function silentStreams(config) {
|
|
|
119
139
|
function runPulse() {
|
|
120
140
|
pulse(state);
|
|
121
141
|
}
|
|
142
|
+
const ticker = intervalSource(state.intervalSec * 1000, { accept: runPulse }, {
|
|
143
|
+
schedule: state.delay, clear: state.clear
|
|
144
|
+
});
|
|
122
145
|
return {
|
|
123
146
|
seen(name, at = state.clk.millis()) {
|
|
124
147
|
state.lastSeen.set(name, at);
|
|
@@ -129,24 +152,24 @@ export default function silentStreams(config) {
|
|
|
129
152
|
bind(sink) {
|
|
130
153
|
state.collector = {
|
|
131
154
|
accept(record) {
|
|
132
|
-
sink.write([record]);
|
|
155
|
+
return sink.write([record]);
|
|
133
156
|
}
|
|
134
157
|
};
|
|
135
158
|
},
|
|
136
159
|
start() {
|
|
160
|
+
if (state.running) { return drained(state); }
|
|
137
161
|
state.running = true;
|
|
138
162
|
runPulse();
|
|
139
|
-
state
|
|
163
|
+
track(state, ticker.start());
|
|
164
|
+
return drained(state);
|
|
140
165
|
},
|
|
141
|
-
stop() {
|
|
166
|
+
async stop() {
|
|
142
167
|
state.running = false;
|
|
143
|
-
|
|
144
|
-
state.clear(state.timer);
|
|
145
|
-
state.timer = undefined;
|
|
146
|
-
}
|
|
168
|
+
track(state, ticker.stop());
|
|
147
169
|
for (const name of [...state.live]) {
|
|
148
170
|
stopOne(state, name);
|
|
149
171
|
}
|
|
172
|
+
await drained(state);
|
|
150
173
|
state.held.clear();
|
|
151
174
|
},
|
|
152
175
|
pulse: runPulse
|
|
@@ -30,25 +30,24 @@ export default function metricsCodec(collector, log = console) {
|
|
|
30
30
|
parsed = JSON.parse(raw.payload);
|
|
31
31
|
} catch {
|
|
32
32
|
log.warn(`Cannot resolve payload on ${raw.topic}: not valid JSON`);
|
|
33
|
-
return;
|
|
33
|
+
return undefined;
|
|
34
34
|
}
|
|
35
35
|
if (typeof parsed === 'number') {
|
|
36
|
-
collector.accept({
|
|
36
|
+
return collector.accept({
|
|
37
37
|
topic: raw.topic,
|
|
38
38
|
ts: Date.now(),
|
|
39
39
|
value: parsed
|
|
40
40
|
});
|
|
41
|
-
return;
|
|
42
41
|
}
|
|
43
42
|
if (typeof parsed === 'object' && parsed !== null && typeof parsed.value === 'number') {
|
|
44
|
-
collector.accept({
|
|
43
|
+
return collector.accept({
|
|
45
44
|
topic: raw.topic,
|
|
46
45
|
ts: parsed.ts || Date.now(),
|
|
47
46
|
value: parsed.value
|
|
48
47
|
});
|
|
49
|
-
return;
|
|
50
48
|
}
|
|
51
49
|
log.warn(`Cannot resolve payload on ${raw.topic}: no numeric value found`);
|
|
50
|
+
return undefined;
|
|
52
51
|
}
|
|
53
52
|
};
|
|
54
53
|
}
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
clock,
|
|
5
|
-
modbusRtuBusSource,
|
|
6
|
-
modbusRtuSource,
|
|
7
|
-
modbusSource,
|
|
8
|
-
mqttSink
|
|
9
|
-
} from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { clock } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import { modbusRtuBusSource, modbusRtuSource, modbusSource, mqttSink } from '../../streams/index.js';
|
|
3
|
+
import ingestBatch from '../ingestBatch.js';
|
|
10
4
|
import { parseModbusDeviceSpec } from './modbusDeviceSpec.js';
|
|
11
5
|
|
|
12
6
|
/**
|
|
@@ -181,24 +175,28 @@ function buildSources(devices, config, collector, clk) {
|
|
|
181
175
|
export default function modbusMqtt(mqtt, devices, config) {
|
|
182
176
|
assertModbusMqttArgs(mqtt, devices, config);
|
|
183
177
|
const clk = clock();
|
|
184
|
-
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
185
178
|
const sink = mqttSink(mqtt, {
|
|
186
179
|
clientId: config.clientId || 'scada-modbus',
|
|
187
180
|
qos: 1
|
|
188
181
|
});
|
|
189
|
-
const collector =
|
|
182
|
+
const collector = ingestBatch(sink, { ...config, size: config.size ?? 5 });
|
|
190
183
|
const sources = buildSources(devices, config, collector, clk);
|
|
191
184
|
return {
|
|
192
|
-
start() {
|
|
193
|
-
sink.start();
|
|
194
|
-
|
|
195
|
-
source.start();
|
|
185
|
+
async start() {
|
|
186
|
+
await sink.start();
|
|
187
|
+
try {
|
|
188
|
+
await Promise.all(sources.map((source) => { return source.start(); }));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await Promise.allSettled(sources.map((source) => { return source.stop(); }));
|
|
191
|
+
await sink.stop();
|
|
192
|
+
throw error;
|
|
196
193
|
}
|
|
197
194
|
},
|
|
198
|
-
stop() {
|
|
199
|
-
|
|
200
|
-
source.stop();
|
|
201
|
-
|
|
195
|
+
async stop() {
|
|
196
|
+
try {
|
|
197
|
+
await Promise.all(sources.map((source) => { return source.stop(); }));
|
|
198
|
+
await collector.stop();
|
|
199
|
+
} finally { await sink.stop(); }
|
|
202
200
|
}
|
|
203
201
|
};
|
|
204
202
|
}
|
|
@@ -260,6 +260,10 @@ export default function modbusPublish(mqtt, config) {
|
|
|
260
260
|
return [row.name, plans[index]];
|
|
261
261
|
}));
|
|
262
262
|
return modbusMqtt(mqtt, specs, {
|
|
263
|
+
budget: config.budget,
|
|
264
|
+
maxRecords: config.maxRecords,
|
|
265
|
+
maxBytes: config.maxBytes,
|
|
266
|
+
writeTimeoutMs: config.writeTimeoutMs,
|
|
263
267
|
address: frame.address,
|
|
264
268
|
clientId: config.clientId || 'scada-modbus',
|
|
265
269
|
count: frame.count,
|
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
circuit,
|
|
4
|
-
clock,
|
|
5
|
-
mqttSource,
|
|
6
|
-
timedBatch
|
|
7
|
-
} from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { mqttSource } from '../../streams/index.js';
|
|
2
|
+
import ingestBatch from '../ingestBatch.js';
|
|
8
3
|
import metricsCodec from './metricsTransformer.js';
|
|
9
4
|
|
|
10
5
|
/**
|
|
11
6
|
* Pipeline for streaming MQTT sensor data to a storage sink.
|
|
12
7
|
*
|
|
13
|
-
* Subscribes to MQTT
|
|
14
|
-
*
|
|
15
|
-
* and
|
|
8
|
+
* Subscribes to MQTT and writes each message before acknowledging it.
|
|
9
|
+
* MQTT.js serializes acknowledgement, so acceptance cannot wait for a larger batch.
|
|
10
|
+
* Bounds queued records and bytes and enforces a durable-write deadline.
|
|
16
11
|
*
|
|
17
12
|
* @example
|
|
18
|
-
* import { clickhouseSink } from '
|
|
13
|
+
* import { clickhouseSink } from '../../streams/index.js';
|
|
19
14
|
* const sink = clickhouseSink('http://localhost:8123', 'scada.metrics');
|
|
20
15
|
* const pipeline = mqttMetrics(
|
|
21
16
|
* 'mqtt://localhost:1883',
|
|
@@ -31,10 +26,10 @@ import metricsCodec from './metricsTransformer.js';
|
|
|
31
26
|
* @param {object} sink - Sink with write(records) method
|
|
32
27
|
* @param {string} topic - MQTT topic pattern to subscribe
|
|
33
28
|
* @param {object} config - Pipeline configuration
|
|
34
|
-
* @param {number} config.size -
|
|
29
|
+
* @param {number} config.size - Legacy option; serial durable acceptance writes one record at a time
|
|
35
30
|
* @param {number} config.interval - Seconds before time-based flush
|
|
36
|
-
* @param {number} config.threshold -
|
|
37
|
-
* @param {number} config.timeout -
|
|
31
|
+
* @param {number} config.threshold - Legacy option (retained for configuration compatibility)
|
|
32
|
+
* @param {number} config.timeout - Legacy option (use writeTimeoutMs for write deadlines)
|
|
38
33
|
* @param {string} [config.clientId] - MQTT client ID for persistent sessions
|
|
39
34
|
* @param {number} [config.sessionExpiryInterval] - Session expiry in seconds (default 3600)
|
|
40
35
|
* @returns {object} Pipeline with start() and stop() methods
|
|
@@ -52,11 +47,12 @@ export default function mqttMetrics(mqtt, sink, topic, config) {
|
|
|
52
47
|
if (!config || typeof config !== 'object') {
|
|
53
48
|
throw new Error('Config must be an object');
|
|
54
49
|
}
|
|
55
|
-
const
|
|
56
|
-
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
57
|
-
const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
|
|
50
|
+
const collector = ingestBatch(sink, { ...config, size: 1 });
|
|
58
51
|
const transformer = metricsCodec(collector);
|
|
59
52
|
const source = mqttSource(mqtt, topic, transformer, {
|
|
53
|
+
budget: config.budget,
|
|
54
|
+
prefetch: config.prefetch,
|
|
55
|
+
maxPacketBytes: config.maxBytes,
|
|
60
56
|
clientId: config.clientId,
|
|
61
57
|
sessionExpiryInterval: config.sessionExpiryInterval
|
|
62
58
|
});
|
|
@@ -65,14 +61,14 @@ export default function mqttMetrics(mqtt, sink, topic, config) {
|
|
|
65
61
|
* Starts the pipeline.
|
|
66
62
|
*/
|
|
67
63
|
start() {
|
|
68
|
-
source.start();
|
|
64
|
+
return source.start();
|
|
69
65
|
},
|
|
70
66
|
/**
|
|
71
67
|
* Stops the pipeline.
|
|
72
68
|
*/
|
|
73
|
-
stop() {
|
|
74
|
-
source.stop();
|
|
75
|
-
collector.stop();
|
|
69
|
+
async stop() {
|
|
70
|
+
await source.stop();
|
|
71
|
+
await collector.stop();
|
|
76
72
|
}
|
|
77
73
|
};
|
|
78
74
|
}
|
|
@@ -70,15 +70,15 @@ export default function registerPublish(plans, origin, collector) {
|
|
|
70
70
|
* @param {Array<number>} registers - uint16 window
|
|
71
71
|
*/
|
|
72
72
|
accept(registers) {
|
|
73
|
-
rows.
|
|
74
|
-
collector.accept({
|
|
73
|
+
return Promise.all(rows.map((item) => {
|
|
74
|
+
return collector.accept({
|
|
75
75
|
payload: JSON.stringify({
|
|
76
76
|
ts: Date.now(),
|
|
77
77
|
value: decodePoint(item.point, origin, registers)
|
|
78
78
|
}),
|
|
79
79
|
topic: item.topic
|
|
80
80
|
});
|
|
81
|
-
});
|
|
81
|
+
}));
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import stompOptions from './stompOptions.js';
|
|
2
|
+
import { stompSource } from '../../streams/index.js';
|
|
2
3
|
import alertCodec from '../codecs/alertCodec.js';
|
|
3
4
|
import alertSink from '../sinks/alertSink.js';
|
|
4
5
|
|
|
@@ -29,20 +30,20 @@ export default function alertPipeline(stomp, pool, config) {
|
|
|
29
30
|
const sink = alertSink(pool);
|
|
30
31
|
const codec = alertCodec(sink, config.translations || {});
|
|
31
32
|
const source = stompSource(stomp, '/exchange/scada.alerts', codec,
|
|
32
|
-
|
|
33
|
+
stompOptions(config));
|
|
33
34
|
return {
|
|
34
35
|
/**
|
|
35
36
|
* Starts the pipeline.
|
|
36
37
|
*/
|
|
37
38
|
start() {
|
|
38
|
-
source.start();
|
|
39
|
+
return source.start();
|
|
39
40
|
},
|
|
40
41
|
/**
|
|
41
42
|
* Stops the pipeline.
|
|
42
43
|
*/
|
|
43
|
-
stop() {
|
|
44
|
-
source.stop();
|
|
45
|
-
sink.stop();
|
|
44
|
+
async stop() {
|
|
45
|
+
await source.stop();
|
|
46
|
+
await sink.stop();
|
|
46
47
|
}
|
|
47
48
|
};
|
|
48
49
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import stompOptions from './stompOptions.js';
|
|
2
|
+
import { stompSource } from '../../streams/index.js';
|
|
2
3
|
import userDecisionCodec from '../codecs/userDecisionCodec.js';
|
|
3
4
|
import userDecisionSink from '../sinks/userDecisionSink.js';
|
|
4
5
|
|
|
@@ -26,20 +27,20 @@ export default function decisionPipeline(stomp, pool, config) {
|
|
|
26
27
|
const sink = userDecisionSink(pool);
|
|
27
28
|
const codec = userDecisionCodec(sink);
|
|
28
29
|
const source = stompSource(stomp, '/exchange/scada.user_decisions', codec,
|
|
29
|
-
|
|
30
|
+
stompOptions(config));
|
|
30
31
|
return {
|
|
31
32
|
/**
|
|
32
33
|
* Starts consuming user decisions from RabbitMQ.
|
|
33
34
|
*/
|
|
34
35
|
start() {
|
|
35
|
-
source.start();
|
|
36
|
+
return source.start();
|
|
36
37
|
},
|
|
37
38
|
/**
|
|
38
39
|
* Stops consuming and releases resources.
|
|
39
40
|
*/
|
|
40
|
-
stop() {
|
|
41
|
-
source.stop();
|
|
42
|
-
pool.end();
|
|
41
|
+
async stop() {
|
|
42
|
+
await source.stop();
|
|
43
|
+
await pool.end();
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
stompSource
|
|
4
|
-
} from '@yarkivaev/source-to-sink';
|
|
1
|
+
import stompOptions from './stompOptions.js';
|
|
2
|
+
import { postgresSink, stompSource } from '../../streams/index.js';
|
|
5
3
|
import segmentDispatch from '../../../domain/segment/dispatch.js';
|
|
6
4
|
import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
|
|
7
5
|
import silenceBudget from '../../../domain/segment/silenceBudget.js';
|
|
@@ -64,14 +62,7 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
|
|
|
64
62
|
interval: config.interval
|
|
65
63
|
});
|
|
66
64
|
const destination = config.segmentsDestination || segmentsIngestDestination;
|
|
67
|
-
const source = stompSource(stomp, destination, coalesce, {
|
|
68
|
-
login: config.login,
|
|
69
|
-
passcode: config.passcode,
|
|
70
|
-
host: config.host,
|
|
71
|
-
serial: false,
|
|
72
|
-
manualAck: true,
|
|
73
|
-
prefetch: config.prefetch
|
|
74
|
-
});
|
|
65
|
+
const source = stompSource(stomp, destination, coalesce, stompOptions(config, { serial: false, manualAck: true }));
|
|
75
66
|
const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
|
|
76
67
|
intervalMs: (config.poll || 5) * 1000
|
|
77
68
|
});
|
|
@@ -82,13 +73,13 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
|
|
|
82
73
|
void repair.catch((error) => {
|
|
83
74
|
processingErrorLog('abut_segments', error, { budgetSeconds: budget });
|
|
84
75
|
});
|
|
85
|
-
source.start();
|
|
76
|
+
await source.start();
|
|
86
77
|
await silence.start();
|
|
87
78
|
},
|
|
88
|
-
stop() {
|
|
89
|
-
silence.stop();
|
|
90
|
-
source.stop();
|
|
91
|
-
pool.end();
|
|
79
|
+
async stop() {
|
|
80
|
+
await silence.stop();
|
|
81
|
+
await source.stop();
|
|
82
|
+
await pool.end();
|
|
92
83
|
}
|
|
93
84
|
};
|
|
94
85
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Preserve driver defaults when an optional pipeline setting was omitted. */
|
|
2
|
+
export default function stompOptions(config, overrides = {}) {
|
|
3
|
+
const options = { login: config.login, passcode: config.passcode, host: config.host,
|
|
4
|
+
prefetch: config.prefetch, maxMessageBytes: config.maxMessageBytes,
|
|
5
|
+
maxPendingBytes: config.maxPendingBytes, budget: config.budget, ...overrides };
|
|
6
|
+
return Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
|
|
7
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Periodic wall-clock sweep that closes stale open segments without waiting for STOMP.
|
|
3
5
|
*
|
|
@@ -18,46 +20,7 @@ export default function silentOpenWatch(closer, budget, options) {
|
|
|
18
20
|
throw new Error(`Budget must be a positive number: ${budget}`);
|
|
19
21
|
}
|
|
20
22
|
const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function claim() {
|
|
25
|
-
const free = !gate.busy;
|
|
26
|
-
gate.busy = true;
|
|
27
|
-
return free;
|
|
28
|
-
}
|
|
29
|
-
function release() {
|
|
30
|
-
gate.busy = false;
|
|
31
|
-
}
|
|
32
|
-
async function tick() {
|
|
33
|
-
if (!claim()) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
await closer.close(budget);
|
|
38
|
-
} catch {
|
|
39
|
-
/* next tick retries */
|
|
40
|
-
}
|
|
41
|
-
release();
|
|
42
|
-
}
|
|
43
|
-
return {
|
|
44
|
-
async start() {
|
|
45
|
-
if (active) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
active = true;
|
|
49
|
-
await tick();
|
|
50
|
-
timer = setInterval(() => {
|
|
51
|
-
void tick();
|
|
52
|
-
}, intervalMs);
|
|
53
|
-
},
|
|
54
|
-
stop() {
|
|
55
|
-
if (!active) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
active = false;
|
|
59
|
-
clearInterval(timer);
|
|
60
|
-
timer = undefined;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
23
|
+
return intervalSource(intervalMs, {
|
|
24
|
+
accept() { return closer.close(budget); }
|
|
25
|
+
}, { immediate: true });
|
|
63
26
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { amqpSource } from '../../streams/index.js';
|
|
2
|
+
import ingestBatch from '../ingestBatch.js';
|
|
3
3
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
4
4
|
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
5
5
|
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
@@ -20,7 +20,7 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
|
20
20
|
onSeen(name);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
23
|
+
return codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -34,10 +34,14 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
|
34
34
|
export function telemetryConsumer(codec, channel, onSeen) {
|
|
35
35
|
return (msg) => {
|
|
36
36
|
if (!msg) {
|
|
37
|
-
return;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const accepted = acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
|
|
40
|
+
if (accepted && typeof accepted.then === 'function') {
|
|
41
|
+
return accepted.then(() => { channel.ack(msg); });
|
|
38
42
|
}
|
|
39
|
-
acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
|
|
40
43
|
channel.ack(msg);
|
|
44
|
+
return undefined;
|
|
41
45
|
};
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -50,38 +54,22 @@ export function telemetryConsumer(codec, channel, onSeen) {
|
|
|
50
54
|
* @param {object} [options] - batch and prefetch options
|
|
51
55
|
* @returns {object} ingest with start and stop
|
|
52
56
|
*/
|
|
53
|
-
function subscribeMetrics(queue, prefetch, codec, onSeen) {
|
|
54
|
-
return async (channel) => {
|
|
55
|
-
await channel.assertQueue(queue, { durable: true });
|
|
56
|
-
channel.prefetch(prefetch);
|
|
57
|
-
const onMessage = telemetryConsumer(codec, channel, onSeen);
|
|
58
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
59
|
-
return tag.consumerTag;
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
57
|
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
64
58
|
const prefetch = options.prefetch || 32;
|
|
65
|
-
const
|
|
66
|
-
const interval = options.interval || 5;
|
|
67
|
-
const threshold = options.threshold || 5;
|
|
68
|
-
const timeout = options.timeout || 60;
|
|
69
|
-
const clk = clock();
|
|
70
|
-
const breaker = circuit(threshold, timeout, clk);
|
|
71
|
-
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
59
|
+
const collector = ingestBatch(sink, options);
|
|
72
60
|
const codec = metricsCodec(collector);
|
|
73
|
-
const session =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
61
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
62
|
+
accept(delivery) {
|
|
63
|
+
return acceptTelemetryDeliver(codec, delivery.fields, delivery.content, options.onSeen);
|
|
64
|
+
}
|
|
65
|
+
}, { ...options, prefetch });
|
|
78
66
|
return {
|
|
79
67
|
start() {
|
|
80
68
|
return session.start();
|
|
81
69
|
},
|
|
82
70
|
async stop() {
|
|
83
71
|
await session.stop();
|
|
84
|
-
collector.stop();
|
|
72
|
+
await collector.stop();
|
|
85
73
|
}
|
|
86
74
|
};
|
|
87
75
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { amqpSource } from '../../streams/index.js';
|
|
2
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -11,11 +11,15 @@ import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
|
11
11
|
export function relayConsumer(sink, channel) {
|
|
12
12
|
return (msg) => {
|
|
13
13
|
if (!msg) {
|
|
14
|
-
return;
|
|
14
|
+
return undefined;
|
|
15
15
|
}
|
|
16
16
|
const record = deliverToMqttRecord(msg.fields, msg.content);
|
|
17
|
-
sink.write([record]);
|
|
17
|
+
const written = sink.write([record]);
|
|
18
|
+
if (written && typeof written.then === 'function') {
|
|
19
|
+
return written.then(() => { channel.ack(msg); });
|
|
20
|
+
}
|
|
18
21
|
channel.ack(msg);
|
|
22
|
+
return undefined;
|
|
19
23
|
};
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -23,7 +27,7 @@ export function relayConsumer(sink, channel) {
|
|
|
23
27
|
* AMQP queue consumer that republishes messages to MQTT using routing key as topic.
|
|
24
28
|
*
|
|
25
29
|
* @example
|
|
26
|
-
* import { mqttSink } from '
|
|
30
|
+
* import { mqttSink } from '../../streams/index.js';
|
|
27
31
|
* const sink = mqttSink('mqtt://localhost:1883', { qos: 1 });
|
|
28
32
|
* const relay = amqpMqttRelay('amqp://localhost', 'scada.telemetry.ingest', sink);
|
|
29
33
|
* relay.start();
|
|
@@ -34,35 +38,26 @@ export function relayConsumer(sink, channel) {
|
|
|
34
38
|
* @param {object} [options] - prefetch count
|
|
35
39
|
* @returns {object} Relay with start and stop
|
|
36
40
|
*/
|
|
37
|
-
function subscribeRelay(queue, prefetch, sink) {
|
|
38
|
-
return async (channel) => {
|
|
39
|
-
await channel.assertQueue(queue, { durable: true });
|
|
40
|
-
channel.prefetch(prefetch);
|
|
41
|
-
const onMessage = relayConsumer(sink, channel);
|
|
42
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
43
|
-
return tag.consumerTag;
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
48
42
|
const prefetch = options.prefetch || 32;
|
|
49
43
|
let opened = false;
|
|
50
|
-
const session =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
44
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
45
|
+
accept(delivery) {
|
|
46
|
+
return sink.write([deliverToMqttRecord(delivery.fields, delivery.content)]);
|
|
47
|
+
}
|
|
48
|
+
}, { ...options, prefetch });
|
|
55
49
|
return {
|
|
56
|
-
start() {
|
|
50
|
+
async start() {
|
|
57
51
|
if (!opened) {
|
|
52
|
+
await sink.start();
|
|
58
53
|
opened = true;
|
|
59
|
-
sink.start();
|
|
60
54
|
}
|
|
61
55
|
return session.start();
|
|
62
56
|
},
|
|
63
57
|
async stop() {
|
|
64
58
|
await session.stop();
|
|
65
|
-
sink.stop();
|
|
59
|
+
await sink.stop();
|
|
60
|
+
opened = false;
|
|
66
61
|
}
|
|
67
62
|
};
|
|
68
63
|
}
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export default function foldingSink(inner, fold, locate) {
|
|
14
14
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
stop() { return inner.stop?.(); },
|
|
16
|
+
async write(records, context = {}) {
|
|
17
|
+
context.signal?.throwIfAborted();
|
|
18
|
+
await inner.write(records, context);
|
|
17
19
|
for (const record of records) {
|
|
20
|
+
context.signal?.throwIfAborted();
|
|
18
21
|
const sample = locate(record);
|
|
19
22
|
if (sample) {
|
|
20
23
|
// eslint-disable-next-line no-await-in-loop
|