@yarkivaev/scada 2.3.114 → 2.3.116

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.
Files changed (42) hide show
  1. package/README.md +109 -0
  2. package/charts/scada/Chart.yaml +2 -2
  3. package/charts/scada/templates/_helpers.tpl +12 -4
  4. package/charts/scada/templates/clickhouse.yaml +3 -3
  5. package/charts/scada/templates/collector.yaml +2 -2
  6. package/charts/scada/templates/namespace.yaml +6 -0
  7. package/charts/scada/templates/postgres.yaml +3 -3
  8. package/charts/scada/templates/processes.yaml +88 -0
  9. package/charts/scada/templates/rabbitmq-config.yaml +91 -0
  10. package/charts/scada/templates/rabbitmq.yaml +54 -113
  11. package/charts/scada/templates/site-server.yaml +5 -3
  12. package/charts/scada/templates/supervisor-sink.yaml +4 -2
  13. package/charts/scada/values.schema.json +22 -0
  14. package/charts/scada/values.yaml +21 -0
  15. package/index.js +3 -0
  16. package/package.json +8 -3
  17. package/src/application/deploy/collectorProcesses.js +43 -0
  18. package/src/application/deploy/expandFlows.js +21 -10
  19. package/src/application/deploy/publishedStreams.js +30 -0
  20. package/src/application/deploy/releaseEdges.js +13 -16
  21. package/src/application/deploy/releaseNodes.js +13 -0
  22. package/src/application/deploy/releaseValues.js +4 -15
  23. package/src/application/deploy/releases.js +3 -1
  24. package/src/application/deploy/subscriptions.js +20 -0
  25. package/src/infrastructure/deploy/executeRelease.js +102 -0
  26. package/src/infrastructure/ingest/durableConsume.js +2 -169
  27. package/src/infrastructure/ingest/modbus/silentStreams.js +9 -11
  28. package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
  29. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +7 -18
  30. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +8 -17
  31. package/src/infrastructure/messaging/rabbitmq/definitions.js +24 -0
  32. package/src/infrastructure/messaging/rabbitmq/reconcile.js +104 -0
  33. package/src/infrastructure/opcua/config.js +38 -0
  34. package/src/infrastructure/opcua/endpoint.js +80 -0
  35. package/src/infrastructure/opcua/index.js +10 -0
  36. package/src/infrastructure/opcua/session.js +60 -0
  37. package/src/infrastructure/opcua/source.js +35 -0
  38. package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
  39. package/src/infrastructure/operators/operatorsSync.js +5 -29
  40. package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
  41. package/src/infrastructure/persistence/metricsSensor.js +9 -6
  42. package/src/infrastructure/sync/operationSyncIngest.js +4 -16
@@ -0,0 +1,60 @@
1
+ import { checkSubscription, watchSubscription } from './subscriptionHealth.js';
2
+ import { AttributeIds, TimestampsToReturn } from 'node-opcua';
3
+
4
+ function deliver(state, channel, value, publish, options) {
5
+ if (state.active && !state.failure && value.statusCode.isGood()) {
6
+ const accepted = Promise.resolve().then(() => {
7
+ if (state.active && !state.failure) {
8
+ return publish(channel, value.value.value);
9
+ }
10
+ return undefined;
11
+ }).catch((error) => {
12
+ try {
13
+ options.onError?.(error, state.connection);
14
+ } catch {
15
+ // Callback isolation.
16
+ }
17
+ }).finally(() => { state.pending.delete(accepted); });
18
+ state.pending.add(accepted);
19
+ }
20
+ }
21
+ async function monitors(subscription, state, connection, publish, options) {
22
+ await Promise.all(connection.channels.map(async (channel) => {
23
+ const item = await subscription.monitor({ nodeId: channel.nodeId, attributeId: AttributeIds.Value },
24
+ { samplingInterval: connection.publishMs, discardOldest: true, queueSize: 10 }, TimestampsToReturn.Both);
25
+ if (!item.statusCode.isGood()) {
26
+ throw new Error(`Cannot monitor ${channel.nodeId}: ${item.statusCode.toString()}`);
27
+ }
28
+ item.on('changed', (value) => { deliver(state, channel, value, publish, options); });
29
+ }));
30
+ }
31
+ function pause(state, milliseconds) {
32
+ return new Promise((resolve) => {
33
+ state.wake = resolve;
34
+ state.timer = setTimeout(resolve, milliseconds);
35
+ }).finally(() => { clearTimeout(state.timer); });
36
+ }
37
+ async function heartbeat(state, activeSession, connection, publish, options) {
38
+ while (state.active) {
39
+ checkSubscription(state, options);
40
+ // Fresh reads are serialized; a slow read cannot overlap the next heartbeat.
41
+ // eslint-disable-next-line no-await-in-loop
42
+ await pause(state, connection.heartbeatMs);
43
+ if (state.active) {
44
+ checkSubscription(state, options);
45
+ // eslint-disable-next-line no-await-in-loop
46
+ const values = await activeSession.read(connection.channels.map(({ nodeId }) => { return { nodeId, attributeId: AttributeIds.Value }; }), 0);
47
+ values.forEach((value, index) => { deliver(state, connection.channels[index], value, publish, options); });
48
+ }
49
+ }
50
+ }
51
+ export default async function session(state, client, connection, publish, options) {
52
+ const activeSession = await client.createSession();
53
+ if (state.active) {
54
+ const subscription = await activeSession.createSubscription2({ requestedPublishingInterval: connection.publishMs,
55
+ requestedLifetimeCount: 30, requestedMaxKeepAliveCount: 3, maxNotificationsPerPublish: 0, publishingEnabled: true, priority: 0 });
56
+ watchSubscription(state, subscription);
57
+ await monitors(subscription, state, connection, publish, options);
58
+ await heartbeat(state, activeSession, connection, publish, options);
59
+ }
60
+ }
@@ -0,0 +1,35 @@
1
+ import { positive, validateConnections } from './config.js';
2
+ export { validateConnections } from './config.js';
3
+
4
+ /** Independent OPC UA endpoints delivering opaque channel metadata and values. */
5
+ export default function opcuaSource(connections, collector, options = {}) {
6
+ const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, ...options };
7
+ validateConnections(connections);
8
+ if (!collector || typeof collector.accept !== 'function') {
9
+ throw new TypeError('Collector must have an accept() method');
10
+ }
11
+ ['retryMs', 'connectTimeoutMs', 'requestTimeoutMs'].forEach((name) => { positive(settings[name], name); });
12
+ let endpoints;
13
+ let stopping = Promise.resolve();
14
+ let revision = 0;
15
+ return {
16
+ async start() {
17
+ revision += 1;
18
+ const requested = revision;
19
+ await stopping;
20
+ // Importing the package or validating a factory never loads node-opcua.
21
+ const { default: endpoint } = await import('./endpoint.js');
22
+ if (requested === revision) {
23
+ endpoints ??= connections.map((connection) => {
24
+ return endpoint(connection, (channel, value) => { return collector.accept({ channel, value }); }, settings);
25
+ });
26
+ endpoints.forEach((item) => { item.start(); });
27
+ }
28
+ },
29
+ stop() {
30
+ revision += 1;
31
+ stopping = Promise.all((endpoints ?? []).map((item) => { return item.stop(); }));
32
+ return stopping;
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,28 @@
1
+ function failed(state, message) {
2
+ if (state.active) {
3
+ state.failure = new Error(message);
4
+ state.wake?.();
5
+ }
6
+ }
7
+ export function watchSubscription(state, subscription) {
8
+ state.lastPublish = Date.now();
9
+ state.subscription = subscription;
10
+ subscription.on('keepalive', () => { state.lastPublish = Date.now(); });
11
+ subscription.on('received_notifications', () => { state.lastPublish = Date.now(); });
12
+ subscription.on('terminated', () => { failed(state, 'OPC UA subscription terminated'); });
13
+ subscription.on('status_changed', (status) => {
14
+ if (!status.isGood()) {
15
+ failed(state, `OPC UA subscription status: ${status.toString()}`);
16
+ }
17
+ });
18
+ }
19
+ export function checkSubscription(state, options) {
20
+ const { subscription } = state;
21
+ const silence = Math.max(subscription.publishingInterval * subscription.maxKeepAliveCount * 3, options.requestTimeoutMs);
22
+ if (Date.now() - state.lastPublish > silence) {
23
+ failed(state, 'OPC UA subscription stopped publishing');
24
+ }
25
+ if (state.failure) {
26
+ throw state.failure;
27
+ }
28
+ }
@@ -1,3 +1,5 @@
1
+ import { intervalSource } from '@yarkivaev/source-to-sink';
2
+
1
3
  const DEFAULT_INTERVAL_MS = 30000;
2
4
 
3
5
  async function refresh(source, provider) {
@@ -25,33 +27,7 @@ async function refresh(source, provider) {
25
27
  */
26
28
  export default function operatorsSync(source, provider, options) {
27
29
  const intervalMs = options && options.intervalMs ? options.intervalMs : DEFAULT_INTERVAL_MS;
28
- let timer;
29
- let active = false;
30
- async function tick() {
31
- try {
32
- await refresh(source, provider);
33
- } catch {
34
- /* next tick retries; stale snapshot remains */
35
- }
36
- }
37
- return {
38
- async start() {
39
- if (active) {
40
- return;
41
- }
42
- active = true;
43
- await tick();
44
- timer = setInterval(() => {
45
- void tick();
46
- }, intervalMs);
47
- },
48
- stop() {
49
- if (!active) {
50
- return;
51
- }
52
- active = false;
53
- clearInterval(timer);
54
- timer = undefined;
55
- }
56
- };
30
+ return intervalSource(intervalMs, {
31
+ accept() { return refresh(source, provider); }
32
+ }, { immediate: true });
57
33
  }
@@ -9,14 +9,16 @@
9
9
  * const sub = hub.watch('m1/V', since, 1000, onRow, { unit: 'V', clock });
10
10
  * sub.cancel();
11
11
  */
12
+ import { intervalSource } from '@yarkivaev/source-to-sink';
12
13
  import pollTopicCursors from './pollTopicCursors.js';
13
14
 
14
15
  const hubs = new WeakMap();
15
16
 
16
17
  function defaultSchedule(step, fn) {
17
- const timer = setInterval(fn, step);
18
+ const source = intervalSource(step, { accept: fn });
19
+ void source.start();
18
20
  return () => {
19
- clearInterval(timer);
21
+ return source.stop();
20
22
  };
21
23
  }
22
24
 
@@ -74,21 +76,25 @@ function cursorsOf(bucket) {
74
76
  return cursors;
75
77
  }
76
78
 
79
+ function advance(watcher, timestamp) {
80
+ if (timestamp.getTime() > watcher.lastTs.getTime()) {
81
+ watcher.lastTs = timestamp;
82
+ }
83
+ }
84
+
77
85
  function dispatch(bucket, rows) {
78
- rows.forEach((row) => {
86
+ return rows.reduce(async (previous, row) => {
87
+ await previous;
79
88
  const group = bucket.watchers.get(row.topic);
80
- if (!group) {
81
- return;
82
- }
89
+ if (!group) { return; }
83
90
  const timestamp = parseTimestamp(row.ts);
84
- group.forEach((watcher) => {
85
- if (timestamp.getTime() <= watcher.lastTs.getTime()) {
86
- return;
87
- }
88
- watcher.callback({ timestamp, value: row.value, unit: watcher.unit });
89
- watcher.lastTs = timestamp;
90
- });
91
- });
91
+ await [...group].reduce(async (previousWatcher, watcher) => {
92
+ await previousWatcher;
93
+ if (timestamp.getTime() <= watcher.lastTs.getTime()) { return; }
94
+ await watcher.callback({ timestamp, value: row.value, unit: watcher.unit });
95
+ advance(watcher, timestamp);
96
+ }, Promise.resolve());
97
+ }, Promise.resolve());
92
98
  }
93
99
 
94
100
  function buildHub(connection, schedule) {
@@ -106,7 +112,7 @@ function buildHub(connection, schedule) {
106
112
  cursorsOf(bucket),
107
113
  bucket.clock()
108
114
  );
109
- dispatch(bucket, rows);
115
+ await dispatch(bucket, rows);
110
116
  } catch (err) {
111
117
  console.error( // eslint-disable-line no-console
112
118
  `Stream batch failed on ${connection.url()}:`,
@@ -1,3 +1,4 @@
1
+ import { intervalSource } from '@yarkivaev/source-to-sink';
1
2
  import seriesRows, { parseTimestamp } from './seriesRows.js';
2
3
 
3
4
  function pollStream(spec) {
@@ -6,21 +7,23 @@ function pollStream(spec) {
6
7
  return new Date();
7
8
  });
8
9
  let lastTs = since;
9
- const timer = setInterval(async () => {
10
+ const source = intervalSource(step, { async accept() {
10
11
  try {
11
12
  const rows = await read.poll(topic, lastTs.toISOString(), time().toISOString());
12
- rows.forEach((row) => {
13
+ await rows.reduce(async (previous, row) => {
14
+ await previous;
13
15
  const timestamp = parseTimestamp(row.ts);
14
- callback({ timestamp, value: row.value, unit });
16
+ await callback({ timestamp, value: row.value, unit });
15
17
  lastTs = timestamp;
16
- });
18
+ }, Promise.resolve());
17
19
  } catch {
18
20
  /* next poll retries */
19
21
  }
20
- }, step);
22
+ } });
23
+ void source.start();
21
24
  return {
22
25
  cancel() {
23
- clearInterval(timer);
26
+ return source.stop();
24
27
  }
25
28
  };
26
29
  }
@@ -1,4 +1,4 @@
1
- import durableConsume from '../ingest/durableConsume.js';
1
+ import { amqpSource } from '@yarkivaev/source-to-sink';
2
2
  import operationCodec from './operationCodec.js';
3
3
  import operationSyncSink from './operationSyncSink.js';
4
4
 
@@ -43,25 +43,13 @@ export function operationConsumer(codec, channel) {
43
43
  * const ingest = operationSyncIngest(amqpUrl, 'scada.operations.ingest', dataAccess.operations);
44
44
  * await ingest.start();
45
45
  */
46
- function subscribeOperations(queue, prefetch, codec) {
47
- return async (channel) => {
48
- await channel.assertQueue(queue, { durable: true });
49
- channel.prefetch(prefetch);
50
- const onMessage = operationConsumer(codec, channel);
51
- const tag = await channel.consume(queue, onMessage, { noAck: false });
52
- return tag.consumerTag;
53
- };
54
- }
55
-
56
46
  export default function operationSyncIngest(amqpUrl, queue, operations, options = {}) {
57
47
  const prefetch = options.prefetch || 32;
58
48
  const sink = operationSyncSink(operations);
59
49
  const codec = operationCodec(sink);
60
- const session = durableConsume(
61
- amqpUrl,
62
- subscribeOperations(queue, prefetch, codec),
63
- options
64
- );
50
+ const session = amqpSource(amqpUrl, queue, {
51
+ accept(delivery) { return acceptOperationDeliver(codec, delivery.content); }
52
+ }, { ...options, prefetch });
65
53
  return {
66
54
  start() {
67
55
  return session.start();