@yarkivaev/scada 2.3.116 → 2.4.1
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 +90 -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 +11 -5
- 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/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -2
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
- 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/telemetry/amqpMetricsIngest.js +10 -11
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
- 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/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +12 -2
- package/src/infrastructure/opcua/session.js +20 -10
- package/src/infrastructure/opcua/source.js +6 -3
- 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 +105 -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 +1 -1
|
@@ -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,4 +1,5 @@
|
|
|
1
|
-
import { amqpSource
|
|
1
|
+
import { amqpSource } from '../../streams/index.js';
|
|
2
|
+
import ingestBatch from '../ingestBatch.js';
|
|
2
3
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
4
|
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
4
5
|
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
@@ -33,10 +34,14 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
|
33
34
|
export function telemetryConsumer(codec, channel, onSeen) {
|
|
34
35
|
return (msg) => {
|
|
35
36
|
if (!msg) {
|
|
36
|
-
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); });
|
|
37
42
|
}
|
|
38
|
-
acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
|
|
39
43
|
channel.ack(msg);
|
|
44
|
+
return undefined;
|
|
40
45
|
};
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -51,13 +56,7 @@ export function telemetryConsumer(codec, channel, onSeen) {
|
|
|
51
56
|
*/
|
|
52
57
|
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
53
58
|
const prefetch = options.prefetch || 32;
|
|
54
|
-
const
|
|
55
|
-
const interval = options.interval || 5;
|
|
56
|
-
const threshold = options.threshold || 5;
|
|
57
|
-
const timeout = options.timeout || 60;
|
|
58
|
-
const clk = clock();
|
|
59
|
-
const breaker = circuit(threshold, timeout, clk);
|
|
60
|
-
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
59
|
+
const collector = ingestBatch(sink, options);
|
|
61
60
|
const codec = metricsCodec(collector);
|
|
62
61
|
const session = amqpSource(amqpUrl, queue, {
|
|
63
62
|
accept(delivery) {
|
|
@@ -70,7 +69,7 @@ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
|
70
69
|
},
|
|
71
70
|
async stop() {
|
|
72
71
|
await session.stop();
|
|
73
|
-
collector.stop();
|
|
72
|
+
await collector.stop();
|
|
74
73
|
}
|
|
75
74
|
};
|
|
76
75
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { amqpSource } from '
|
|
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();
|
|
@@ -43,10 +47,10 @@ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
|
43
47
|
}
|
|
44
48
|
}, { ...options, prefetch });
|
|
45
49
|
return {
|
|
46
|
-
start() {
|
|
50
|
+
async start() {
|
|
47
51
|
if (!opened) {
|
|
52
|
+
await sink.start();
|
|
48
53
|
opened = true;
|
|
49
|
-
sink.start();
|
|
50
54
|
}
|
|
51
55
|
return session.start();
|
|
52
56
|
},
|
|
@@ -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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getDefaultCertificateManager } from 'node-opcua';
|
|
2
|
+
|
|
3
|
+
let initialization;
|
|
4
|
+
/** node-opcua's shared manager is not safe to initialize concurrently on a cold PKI. */
|
|
5
|
+
export default function certificates() {
|
|
6
|
+
initialization ??= getDefaultCertificateManager('PKI').initialize().catch((error) => {
|
|
7
|
+
initialization = undefined;
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
return initialization;
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function reservation(pending, bytes, size, options) {
|
|
2
|
+
if (pending.size >= options.maxPending || bytes + size > options.maxPendingBytes) {
|
|
3
|
+
throw new Error('OPC UA delivery capacity exceeded; live value dropped');
|
|
4
|
+
}
|
|
5
|
+
return options.budget?.reserve({ bytes: size, records: 1 }) ?? (() => { return undefined; });
|
|
6
|
+
}
|
|
7
|
+
/** Deadline bounds the caller; capacity stays reserved until the actual write settles. */
|
|
8
|
+
export default function delivery(publish, options) {
|
|
9
|
+
const pending = new Set();
|
|
10
|
+
let bytes = 0;
|
|
11
|
+
return async (channel, value, context = {}) => {
|
|
12
|
+
const size = Buffer.byteLength(JSON.stringify({ channel, value }));
|
|
13
|
+
const releaseBudget = reservation(pending, bytes, size, options);
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
function abort() { controller.abort(context.signal.reason); }
|
|
16
|
+
context.signal?.addEventListener('abort', abort, { once: true });
|
|
17
|
+
const actual = Promise.resolve().then(() => {
|
|
18
|
+
if (context.signal?.aborted) { abort(); }
|
|
19
|
+
controller.signal.throwIfAborted();
|
|
20
|
+
return publish(channel, value, { signal: controller.signal });
|
|
21
|
+
});
|
|
22
|
+
pending.add(actual);
|
|
23
|
+
bytes += size;
|
|
24
|
+
function release() { pending.delete(actual); bytes -= size; releaseBudget(); }
|
|
25
|
+
actual.then(release, release);
|
|
26
|
+
let timer;
|
|
27
|
+
const deadline = new Promise((resolve, reject) => {
|
|
28
|
+
timer = setTimeout(() => {
|
|
29
|
+
controller.abort(new Error('OPC UA delivery deadline exceeded; outcome uncertain'));
|
|
30
|
+
reject(controller.signal.reason);
|
|
31
|
+
}, options.deliveryTimeoutMs);
|
|
32
|
+
});
|
|
33
|
+
return await Promise.race([actual, deadline]).finally(() => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
context.signal?.removeEventListener('abort', abort);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { OPCUAClient } from 'node-opcua';
|
|
2
|
+
import { setMaxListeners } from 'node:events';
|
|
3
|
+
import delivery from './delivery.js';
|
|
2
4
|
import session from './session.js';
|
|
3
5
|
|
|
4
6
|
function report(options, error, connection) {
|
|
@@ -26,11 +28,17 @@ function disconnection(client) {
|
|
|
26
28
|
return pending;
|
|
27
29
|
};
|
|
28
30
|
}
|
|
31
|
+
function attemptState(connection, options) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
// Each admitted delivery registers one cancellation listener on this bounded fan-out.
|
|
34
|
+
setMaxListeners(options.maxPending + 1, controller.signal);
|
|
35
|
+
return { active: true, connection, wake: null, timer: null, pending: new Set(), controller };
|
|
36
|
+
}
|
|
29
37
|
async function attempt(state, connection, publish, options) {
|
|
30
38
|
const client = clientFor(options);
|
|
31
39
|
const close = disconnection(client);
|
|
32
40
|
state.close = close;
|
|
33
|
-
const current =
|
|
41
|
+
const current = attemptState(connection, options);
|
|
34
42
|
state.current = current;
|
|
35
43
|
try {
|
|
36
44
|
await client.connect(connection.url);
|
|
@@ -58,18 +66,20 @@ async function run(state, connection, publish, options) {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
export default function endpoint(connection, publish, options) {
|
|
69
|
+
const send = delivery(publish, options);
|
|
61
70
|
const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
|
|
62
71
|
return {
|
|
63
72
|
start() {
|
|
64
73
|
if (!state.active) {
|
|
65
74
|
state.active = true;
|
|
66
|
-
state.task = run(state, connection,
|
|
75
|
+
state.task = run(state, connection, send, options);
|
|
67
76
|
}
|
|
68
77
|
},
|
|
69
78
|
async stop() {
|
|
70
79
|
state.active = false;
|
|
71
80
|
if (state.current) {
|
|
72
81
|
state.current.active = false;
|
|
82
|
+
state.current.controller.abort();
|
|
73
83
|
state.current.wake?.();
|
|
74
84
|
}
|
|
75
85
|
state.wake?.();
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import { checkSubscription, watchSubscription } from './subscriptionHealth.js';
|
|
2
2
|
import { AttributeIds, TimestampsToReturn } from 'node-opcua';
|
|
3
3
|
|
|
4
|
-
function
|
|
5
|
-
|
|
4
|
+
function report(state, options, error) {
|
|
5
|
+
try { options.onError?.(error, state.connection); } catch {
|
|
6
|
+
// Callback isolation.
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function admitted(state, options) {
|
|
10
|
+
if (state.pending.size < options.maxPending) { return true; }
|
|
11
|
+
if (!state.overflow) {
|
|
12
|
+
state.overflow = true;
|
|
13
|
+
report(state, options, new Error('OPC UA notification capacity exceeded; live value dropped'));
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
export function deliver(state, channel, value, publish, options) {
|
|
18
|
+
if (state.active && !state.failure && value.statusCode.isGood() && admitted(state, options)) {
|
|
6
19
|
const accepted = Promise.resolve().then(() => {
|
|
7
20
|
if (state.active && !state.failure) {
|
|
8
|
-
return publish(channel, value.value.value);
|
|
21
|
+
return publish(channel, value.value.value, { signal: state.controller.signal });
|
|
9
22
|
}
|
|
10
23
|
return undefined;
|
|
11
|
-
}).catch((error) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Callback isolation.
|
|
16
|
-
}
|
|
17
|
-
}).finally(() => { state.pending.delete(accepted); });
|
|
24
|
+
}).catch((error) => { report(state, options, error); }).finally(() => {
|
|
25
|
+
state.pending.delete(accepted);
|
|
26
|
+
state.overflow = false;
|
|
27
|
+
});
|
|
18
28
|
state.pending.add(accepted);
|
|
19
29
|
}
|
|
20
30
|
}
|
|
@@ -3,12 +3,13 @@ export { validateConnections } from './config.js';
|
|
|
3
3
|
|
|
4
4
|
/** Independent OPC UA endpoints delivering opaque channel metadata and values. */
|
|
5
5
|
export default function opcuaSource(connections, collector, options = {}) {
|
|
6
|
-
const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, ...options };
|
|
6
|
+
const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, deliveryTimeoutMs: 5000, maxPending: 100, maxPendingBytes: 1048576, ...options };
|
|
7
7
|
validateConnections(connections);
|
|
8
8
|
if (!collector || typeof collector.accept !== 'function') {
|
|
9
9
|
throw new TypeError('Collector must have an accept() method');
|
|
10
10
|
}
|
|
11
|
-
['retryMs', 'connectTimeoutMs', 'requestTimeoutMs'].forEach((name) => { positive(settings[name], name); });
|
|
11
|
+
['retryMs', 'connectTimeoutMs', 'requestTimeoutMs', 'deliveryTimeoutMs', 'maxPending', 'maxPendingBytes'].forEach((name) => { positive(settings[name], name); });
|
|
12
|
+
if (!Number.isSafeInteger(settings.maxPending)) { throw new TypeError('maxPending must be a safe integer'); }
|
|
12
13
|
let endpoints;
|
|
13
14
|
let stopping = Promise.resolve();
|
|
14
15
|
let revision = 0;
|
|
@@ -19,9 +20,11 @@ export default function opcuaSource(connections, collector, options = {}) {
|
|
|
19
20
|
await stopping;
|
|
20
21
|
// Importing the package or validating a factory never loads node-opcua.
|
|
21
22
|
const { default: endpoint } = await import('./endpoint.js');
|
|
23
|
+
const { default: certificates } = await import('./certificates.js');
|
|
24
|
+
await certificates();
|
|
22
25
|
if (requested === revision) {
|
|
23
26
|
endpoints ??= connections.map((connection) => {
|
|
24
|
-
return endpoint(connection, (channel, value) => { return collector.accept({ channel, value }); }, settings);
|
|
27
|
+
return endpoint(connection, (channel, value, context) => { return collector.accept({ channel, value }, context); }, settings);
|
|
25
28
|
});
|
|
26
29
|
endpoints.forEach((item) => { item.start(); });
|
|
27
30
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Atomic state compare-and-swap; distributed ownership must be integrated separately. */
|
|
2
|
+
export default function taskStatePg(pool, { maxBytes = 1048576 } = {}) {
|
|
3
|
+
return {
|
|
4
|
+
async load(taskId) {
|
|
5
|
+
const result = await pool.query('SELECT version, document FROM collector_task_state WHERE task_id = $1', [taskId]);
|
|
6
|
+
if (!result.rows.length) { return null; }
|
|
7
|
+
return { ...result.rows[0].document, version: Number(result.rows[0].version) };
|
|
8
|
+
},
|
|
9
|
+
async commit(input) {
|
|
10
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0) { throw new TypeError('Invalid expectedVersion'); }
|
|
11
|
+
if (input.ownership !== undefined) { throw new Error('This store has no external ownership integration'); }
|
|
12
|
+
const { expectedVersion } = input;
|
|
13
|
+
const document = { taskId: input.taskId, stateVersion: input.stateVersion, compatibilityKey: input.compatibilityKey,
|
|
14
|
+
checkpoint: input.checkpoint, pending: input.pending };
|
|
15
|
+
const json = JSON.stringify(document);
|
|
16
|
+
if (Buffer.byteLength(json) > maxBytes) { throw new Error('Task state capacity exceeded'); }
|
|
17
|
+
const result = expectedVersion === 0
|
|
18
|
+
? await pool.query('INSERT INTO collector_task_state (task_id, version, document) VALUES ($1, 1, $2::jsonb) ON CONFLICT DO NOTHING RETURNING version', [input.taskId, json])
|
|
19
|
+
: await pool.query('UPDATE collector_task_state SET version = version + 1, document = $2::jsonb, updated_at = now() WHERE task_id = $1 AND version = $3 RETURNING version', [input.taskId, json, expectedVersion]);
|
|
20
|
+
if (!result.rows.length) { throw new Error('Task state conflict'); }
|
|
21
|
+
return Number(result.rows[0].version);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# SCADA stream adapters
|
|
2
|
+
|
|
3
|
+
Protocol and storage adapters are exported by `@yarkivaev/scada/streams`.
|
|
4
|
+
Generic batching, circuit and periodic sources are imported from `@yarkivaev/source-to-sink`.
|
|
5
|
+
|
|
6
|
+
## Components
|
|
7
|
+
|
|
8
|
+
| Component | Description |
|
|
9
|
+
|-----------|-------------|
|
|
10
|
+
| `batch(sink, size, circuit)` | Collects records and flushes to sink when size is reached |
|
|
11
|
+
| `circuit(threshold, timeout, clock)` | Circuit breaker for failure isolation |
|
|
12
|
+
| `timedBatch(collector, interval)` | Adds time-based auto-flush to a collector |
|
|
13
|
+
| `clock()` | System time provider |
|
|
14
|
+
| `pollingSource(fetch, interval, collector, clock)` | Generic polling source with time window |
|
|
15
|
+
| `clickhouseSink(url, table)` | ClickHouse sink adapter |
|
|
16
|
+
| `mqttSource(url, topic, collector)` | MQTT subscription source |
|
|
17
|
+
| `lokiSource(url, query, interval, collector, clock)` | Loki polling source |
|
|
18
|
+
|
|
19
|
+
### STOMP delivery window
|
|
20
|
+
|
|
21
|
+
`stompSource(url, destination, collector, { prefetch: 32 })` bounds broker deliveries
|
|
22
|
+
that are waiting for acknowledgement. The default is 32; use a positive safe
|
|
23
|
+
integer to tune throughput. A zero (unlimited) window is rejected because a
|
|
24
|
+
backlog can otherwise exhaust the consumer's heap. With `manualAck: true`, the
|
|
25
|
+
collector remains responsible for settling frames. If it flushes only by count,
|
|
26
|
+
its batch must not exceed the prefetch window; a timed flush also avoids stalls.
|
|
27
|
+
|
|
28
|
+
Manual collectors may call `settle.nack(false)` to dead-letter invalid messages. Configure a durable dead-letter queue and broker policy before enabling this; without a route RabbitMQ discards rejected messages. `settle.nack()` retains retry behaviour.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Transport sources and lifecycle
|
|
32
|
+
|
|
33
|
+
The sources below receive a collector with `accept(record)`. Return a promise
|
|
34
|
+
that resolves when processing is accepted; rejecting it reports a processing
|
|
35
|
+
failure. Keep application decoding, routing, SQL queries and cursor updates in
|
|
36
|
+
the collector. Await `stop()` before stopping downstream collectors or sinks,
|
|
37
|
+
so in-flight acceptance can finish. A collector that never settles also prevents
|
|
38
|
+
a draining stop from finishing.
|
|
39
|
+
|
|
40
|
+
### Serial chunks
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
import { serialSource } from '@yarkivaev/scada/streams';
|
|
44
|
+
|
|
45
|
+
const source = serialSource([
|
|
46
|
+
{ path: '/dev/ttyUSB0', baudRate: 9600, dataBits: 8, parity: 'none', stopBits: 1 }
|
|
47
|
+
], {
|
|
48
|
+
accept({ device, chunk }) {
|
|
49
|
+
return decodeAndStore(device.path, chunk);
|
|
50
|
+
}
|
|
51
|
+
}, { retryMs: 1000, onError: (error, device) => console.error(device.path, error) });
|
|
52
|
+
|
|
53
|
+
await source.start();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each port has its own open/retry loop. `chunk` is the original buffer and `device`
|
|
57
|
+
is the original configuration object; framing and decoding belong in the
|
|
58
|
+
collector. Duplicate paths are rejected. Errors or a closed port trigger cleanup
|
|
59
|
+
and retry without interrupting other devices. `start()` initiates opens;
|
|
60
|
+
`stop()` waits for pending opens, closes ports, removes listeners and drains
|
|
61
|
+
in-flight acceptance before restart.
|
|
62
|
+
|
|
63
|
+
For testing or another port implementation, pass `openPort(device)`, returning a
|
|
64
|
+
port or promise for a port. The port supports EventEmitter `data`, `error` and
|
|
65
|
+
`close` events plus `close(callback)`. The default opens `serialport` lazily.
|
|
66
|
+
Serial input is a push stream; the source does not impose a collector backlog
|
|
67
|
+
limit or pause the device.
|
|
68
|
+
|
|
69
|
+
### Periodic collection
|
|
70
|
+
|
|
71
|
+
```javascript
|
|
72
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
73
|
+
|
|
74
|
+
const source = intervalSource(1000, {
|
|
75
|
+
async accept({ ts }) {
|
|
76
|
+
const records = await readChanges(ts);
|
|
77
|
+
await publish(records);
|
|
78
|
+
// Advance a cursor only after publication succeeds.
|
|
79
|
+
}
|
|
80
|
+
}, { immediate: true, onError: console.error });
|
|
81
|
+
|
|
82
|
+
await source.start();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`intervalMs` must be positive. Ticks carry a Unix timestamp in milliseconds.
|
|
86
|
+
The default `immediate: false` waits one interval before the first tick. With
|
|
87
|
+
`immediate: true`, `start()` waits for the first acceptance to settle. Subsequent
|
|
88
|
+
ticks wait the interval after the previous acceptance settles, so slow work never
|
|
89
|
+
overlaps another tick. Rejections call `onError` and scheduling continues.
|
|
90
|
+
`stop()` cancels scheduling and drains the active tick; restart waits for that
|
|
91
|
+
drain. Tests can inject `schedule(callback, milliseconds)` and `clear(handle)`.
|
|
92
|
+
|
|
93
|
+
### AMQP consumption
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
import { amqpSource } from '@yarkivaev/scada/streams';
|
|
97
|
+
|
|
98
|
+
const source = amqpSource('amqp://localhost', 'readings', {
|
|
99
|
+
async accept(delivery) {
|
|
100
|
+
await storeReading(JSON.parse(delivery.content.toString()));
|
|
101
|
+
}
|
|
102
|
+
}, {
|
|
103
|
+
queueOptions: { durable: true },
|
|
104
|
+
exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
|
|
105
|
+
bindings: [{ exchange: 'telemetry', pattern: 'reading.#' }],
|
|
106
|
+
prefetch: 32,
|
|
107
|
+
onError: console.error
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await source.start();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Deliveries contain `content`, `fields`, `properties`, `ack()` and
|
|
114
|
+
`reject(requeue = true)`. By default the source acknowledges only after
|
|
115
|
+
`collector.accept` resolves; rejection requeues the delivery and reports the
|
|
116
|
+
error. Settlement is idempotent within a delivery. Set `ackMode: 'manual'` to
|
|
117
|
+
let the collector choose `ack()` or `reject(false)` for dead-lettering; configure
|
|
118
|
+
a broker dead-letter route if those messages must be retained. A collector that
|
|
119
|
+
settles manually should still await its processing work so stop can drain it.
|
|
120
|
+
|
|
121
|
+
The default queue is durable and the default prefetch is 32. Choose a finite,
|
|
122
|
+
positive prefetch to bound outstanding broker deliveries. With count-based
|
|
123
|
+
batching, the batch must fit within that window, or use a timed flush.
|
|
124
|
+
Connection/channel failure or consumer cancellation causes reconnection with
|
|
125
|
+
backoff from one to 30 seconds. `stop()` cancels the consumer, drains acceptance,
|
|
126
|
+
and closes the channel and connection. `active()` means the lifecycle is running,
|
|
127
|
+
including while reconnecting; it does not prove a broker connection is ready.
|
|
128
|
+
|
|
129
|
+
`amqpSession(url, subscribeChannel, options)` exposes that same lifecycle for
|
|
130
|
+
custom channel topology or consumption. The async `subscribeChannel(channel)`
|
|
131
|
+
returns a consumer tag for cancellation. `options.drain(channel)` can await
|
|
132
|
+
application work before channel closure. Both APIs accept `onError` (or `report`),
|
|
133
|
+
`connect(url)`, `schedule(callback, milliseconds)` and `clear(handle)`; the latter
|
|
134
|
+
three are useful for testing. The default connection uses a ten-second heartbeat.
|
|
135
|
+
|
|
136
|
+
### AMQP publication
|
|
137
|
+
|
|
138
|
+
```javascript
|
|
139
|
+
import { amqpSink } from '@yarkivaev/scada/streams';
|
|
140
|
+
|
|
141
|
+
const sink = amqpSink('amqp://localhost', {
|
|
142
|
+
exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
|
|
143
|
+
maxPending: 1024,
|
|
144
|
+
onError: console.error
|
|
145
|
+
});
|
|
146
|
+
await sink.start();
|
|
147
|
+
await sink.write([{
|
|
148
|
+
exchange: 'telemetry', routingKey: 'reading.temperature',
|
|
149
|
+
body: JSON.stringify({ value: 42 }), options: { persistent: true }
|
|
150
|
+
}]);
|
|
151
|
+
await sink.stop();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`write(records)` resolves after broker publisher confirms for every record. Each
|
|
155
|
+
record requires an exchange, routing key and string or Buffer body; `options`
|
|
156
|
+
passes publication options to amqplib. A confirm does not mean a consumer has
|
|
157
|
+
processed the message or that an unroutable message reached a queue.
|
|
158
|
+
|
|
159
|
+
`maxPending` (default 1024) bounds outstanding confirmations. Writes reject while
|
|
160
|
+
the connection is unavailable, when the channel awaits its `drain` event, or when
|
|
161
|
+
the batch would exceed that bound. There is no unbounded offline queue. A write
|
|
162
|
+
can partially succeed before rejection; callers must account for that when
|
|
163
|
+
choosing retries or advancing cursors.
|
|
164
|
+
|
|
165
|
+
Connection loss or `confirmTimeoutMs` expiry rejects uncertain publications and
|
|
166
|
+
reconnects for future writes. **The sink never automatically replays records**:
|
|
167
|
+
the broker may already have accepted them. Use application idempotency when
|
|
168
|
+
retrying uncertain results. `active()` reports whether a live channel exists.
|
|
169
|
+
`start()` waits for connection and topology setup; a failed initial attempt
|
|
170
|
+
rejects while background retries continue until stopped.
|
|
171
|
+
|
|
172
|
+
Other options are `retryMs` (1000), `connectTimeoutMs` (5000),
|
|
173
|
+
`confirmTimeoutMs` (10000), `onError(error)`, and injectable
|
|
174
|
+
`connect(url, connectionOptions)`. Exchange declarations default to type `topic`
|
|
175
|
+
and durable options when omitted. `stop()` closes the session and rejects pending
|
|
176
|
+
confirms; it does not flush them. For graceful shutdown, first stop and drain
|
|
177
|
+
sources, await outstanding `write()` promises, then stop the sink.
|
|
178
|
+
|
|
179
|
+
## Database sink ownership
|
|
180
|
+
|
|
181
|
+
`clickhouseSink(url, table, options)` owns its client; await its idempotent
|
|
182
|
+
`stop()` to close the client's sockets. `write(records, { signal })` forwards
|
|
183
|
+
cancellation to the installed ClickHouse client's native `abort_signal` API.
|
|
184
|
+
`postgresSink.stop()` ends only a pool created by the sink. An `options.pool`
|
|
185
|
+
pool remains caller-owned and open; explicit transaction clients remain borrowed.
|
|
186
|
+
Both stops have a `stopTimeoutMs` deadline (5000). PostgreSQL rejects already
|
|
187
|
+
aborted writes but does not claim to cancel an in-flight query through AbortSignal.
|
|
188
|
+
Stop/drain sources before closing their owned database sink.
|
|
189
|
+
|
|
190
|
+
## Bounded operations and shutdown
|
|
191
|
+
|
|
192
|
+
Await lifecycle methods and writes. In particular, `mqttSink.start()` now waits
|
|
193
|
+
for MQTT CONNACK, and `mqttSink.write(records, { signal })` returns a promise.
|
|
194
|
+
QoS 1/2 waits for the MQTT.js broker-confirmation callback; QoS 0 only confirms
|
|
195
|
+
local handoff and is **not a durable acceptance boundary**. MQTT has no offline
|
|
196
|
+
write queue or automatic replay. Connection loss, cancellation, or a missing
|
|
197
|
+
confirmation retires that session; await `stop()` before starting a fresh session.
|
|
198
|
+
Defaults: `maxPending: 1024`, `maxPendingBytes: 1048576`,
|
|
199
|
+
`connectTimeoutMs: 5000`, `confirmTimeoutMs: 10000`, `stopTimeoutMs: 5000`.
|
|
200
|
+
|
|
201
|
+
`mqttSource` defers PUBACK/PUBCOMP until asynchronous `collector.accept()` finishes.
|
|
202
|
+
This only represents durability if the collector's promise represents durable
|
|
203
|
+
storage. MQTT.js serializes this acceptance hook: use a source-facing batch size
|
|
204
|
+
of one (or flush each acceptance), since a batch requiring future MQTT messages
|
|
205
|
+
cannot fill while the current message waits for durable acknowledgement. Its MQTT 5 receive window defaults to 32 and packet limit to 1 MiB
|
|
206
|
+
(`prefetch`, `maxPacketBytes`). Sources forward an optional second acceptance
|
|
207
|
+
argument `{ signal }`; existing one-argument collectors remain compatible.
|
|
208
|
+
|
|
209
|
+
`boundedBatch(sink, options)` provides bounded asynchronous acceptance. Options
|
|
210
|
+
are `size: 100`, `maxRecords: 1000`, `maxBytes: 1048576`, `maxWaitMs: 1000`, and
|
|
211
|
+
`writeTimeoutMs: 10000`. Acceptance resolves after its sink write; admission
|
|
212
|
+
rejects at capacity. Explicit `flush()`/`stop()` reports failures from the drain
|
|
213
|
+
it awaits. A timed-out write aborts its signal and permanently retires the batch.
|
|
214
|
+
A batch rejection can represent a partially applied external write: retries and
|
|
215
|
+
idempotency belong to the caller.
|
|
216
|
+
|
|
217
|
+
Serial reception pauses the stream while acceptance runs and bounds queued
|
|
218
|
+
chunks/bytes (`maxPending: 32`, `maxPendingBytes: 1048576`). Custom ports must
|
|
219
|
+
implement `pause`/`resume` for flow control; overflow is reported through
|
|
220
|
+
`onError` and permanently retires the source (including a rejected `stop()`).
|
|
221
|
+
There is no implicit drop-and-retry policy. Hardware bytes that arrive despite flow control cannot be recovered
|
|
222
|
+
by this library. `openTimeoutMs` and `stopTimeoutMs` default to 5000;
|
|
223
|
+
`acceptTimeoutMs` defaults to 10000. Timed-out lifecycle operations quarantine
|
|
224
|
+
the instance. RTU buses serialize each slave read and acceptance, recover from
|
|
225
|
+
individual rejected reads, and close late connections without polling them.
|
|
226
|
+
RTU options include `readTimeoutMs: 5000`, `connectTimeoutMs: 5000`,
|
|
227
|
+
`acceptTimeoutMs: 10000`, and `stopTimeoutMs: 5000`.
|
|
228
|
+
|
|
229
|
+
Periodic sources use `acceptTimeoutMs: 10000` and `stopTimeoutMs: 5000`.
|
|
230
|
+
Polling advances its cursor only after all returned rows are accepted and fences
|
|
231
|
+
late fetch results after stop. Fetch receives `(since, until, { signal })`.
|
|
232
|
+
|
|
233
|
+
Loki accepts a sixth options argument: `requestTimeoutMs: 5000`,
|
|
234
|
+
`maxResponseBytes: 1048576`, `pageLimit: 1000`, `queryLagMs: 1000`, and `onError`.
|
|
235
|
+
HTTP body reads are capped and aborted on cancellation/deadline. A response at
|
|
236
|
+
or above the page limit rejects before delivery or cursor advance; this API does
|
|
237
|
+
not claim pagination or a portable historical checkpoint. Query windows lag the
|
|
238
|
+
wall clock to allow ingestion visibility. Entries appearing later than the
|
|
239
|
+
configured lag can still be missed; this is a bounded-lag live source.
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
AMQP sources validate `prefetch` (32), `maxMessageBytes` (1048576), and
|
|
243
|
+
`maxPendingBytes` (prefetch times maxMessageBytes). Oversized or aggregate-overbudget
|
|
244
|
+
payloads are rejected without requeue before calling the collector and reported
|
|
245
|
+
through `onError`. Broker delivery decoding still allocates the incoming message;
|
|
246
|
+
configure broker message-size limits as well for hostile inputs.
|
|
247
|
+
|
|
248
|
+
AMQP cleanup attempts cancellation, draining, channel close, and connection
|
|
249
|
+
close even if an earlier stage hangs. Each cleanup stage uses `stopTimeoutMs`
|
|
250
|
+
(default 5000); the total source cleanup budget can therefore be four times
|
|
251
|
+
that value. Uncertain cleanup/connect deadlines prevent automatic reuse. Native
|
|
252
|
+
AMQP socket destruction is attempted on cleanup timeout. AMQP writes accept
|
|
253
|
+
`{ signal }` and reject pending confirms on cancellation. A confirmed AMQP
|
|
254
|
+
publication still does not prove business processing. Set `record.options.mandatory`
|
|
255
|
+
to require routing: a correlated broker return rejects with `AMQP_UNROUTABLE`
|
|
256
|
+
even when the broker also confirms the publish. Supplied `messageId` values stay
|
|
257
|
+
unchanged for consumer deduplication. The reserved header
|
|
258
|
+
`x-source-to-sink-publish-id` is overwritten with a unique per-publication token
|
|
259
|
+
for return correlation; all other supplied headers are preserved. Callers still
|
|
260
|
+
own poison-message policies.
|
|
261
|
+
|
|
262
|
+
`stompSend` now waits for the broker SEND receipt and graceful disconnect. Its
|
|
263
|
+
`timeoutMs` (10000) bounds the whole operation and destroys the native client on
|
|
264
|
+
timeout. A receipt is not proof of business processing. `stompSource.stop()`
|
|
265
|
+
returns a promise, revokes late ACKs, and bounds acceptance draining with
|
|
266
|
+
`stopTimeoutMs` (5000). Read/accept waits use `acceptTimeoutMs` (10000). Native STOMP frame streams are
|
|
267
|
+
limited before concatenation by `maxMessageBytes` (1048576); `maxPendingBytes`
|
|
268
|
+
(default prefetch times maxMessageBytes) caps bodies held by actual acceptance.
|
|
269
|
+
Oversized/read-failed frames retire the channel; shared budget leases remain
|
|
270
|
+
held through actual acceptance, including after timeout.
|
|
271
|
+
The upstream STOMP Channel API initiates close without exposing a transport-close
|
|
272
|
+
completion callback; source shutdown proves callback revocation/draining, not
|
|
273
|
+
physical socket closure. Manual ACK collectors remain responsible for settlement
|
|
274
|
+
and poison-message policy.
|
|
275
|
+
|
|
276
|
+
AMQP, MQTT, serial, STOMP sources, and `boundedBatch` accept an optional shared `budget` with synchronous
|
|
277
|
+
`reserve({ bytes, records: 1 })`, returning an idempotent release function or
|
|
278
|
+
throwing on capacity. Source leases cover actual acceptance; batch leases include queued records and
|
|
279
|
+
remain held until the actual sink write settles. Leases release on completion
|
|
280
|
+
or rejection, including never-written queued records when a batch retires. A timed-out collector that has not actually settled
|
|
281
|
+
keeps its reservation; creating another source cannot reclaim those retained
|
|
282
|
+
bytes. These counters bound retained payload admission, not native decoder or
|
|
283
|
+
transport heap allocations.
|
|
284
|
+
|
|
285
|
+
A deadline bounds waiting; it does not prove an already-sent external operation
|
|
286
|
+
was cancelled. Callers must await shutdown, treat deadline rejection as an
|
|
287
|
+
uncertain resource/outcome, and quarantine it rather than immediately reuse it.
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Cancels the driver and revokes its result; callers still own resource cleanup. */
|
|
2
|
+
export default function abortable(promise, signal, cancel) {
|
|
3
|
+
if (!signal) { return promise; }
|
|
4
|
+
const state = {};
|
|
5
|
+
function abort() {
|
|
6
|
+
const error = signal.reason || new Error('Operation aborted');
|
|
7
|
+
try { cancel(); } catch (failure) { state.reject(failure); return; }
|
|
8
|
+
state.reject(error);
|
|
9
|
+
}
|
|
10
|
+
const stopped = new Promise((resolve, reject) => { state.reject = reject; });
|
|
11
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
12
|
+
if (signal.aborted) { abort(); }
|
|
13
|
+
return Promise.race([promise, stopped]).finally(() => { signal.removeEventListener('abort', abort); });
|
|
14
|
+
}
|