@yarkivaev/scada 2.3.115 → 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.
- package/README.md +68 -0
- package/package.json +6 -3
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/modbus/silentStreams.js +9 -11
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +7 -18
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +8 -17
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +80 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +60 -0
- package/src/infrastructure/opcua/source.js +35 -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/sync/operationSyncIngest.js +4 -16
package/README.md
CHANGED
|
@@ -269,6 +269,30 @@ Opaque operation `kind` strings and generic ports (`latestForMachine`, list/upse
|
|
|
269
269
|
| Export ports `Query` / `Stream` / `Sink` / `Job` | Export jobs + destination adapters |
|
|
270
270
|
| ClickHouse + PG adapters | Plant wiring (`sokolPlant` / `edgePlant`) |
|
|
271
271
|
|
|
272
|
+
### Stream transport ownership
|
|
273
|
+
|
|
274
|
+
`@yarkivaev/source-to-sink` owns reusable Node.js sources, sinks and transport
|
|
275
|
+
lifecycle: serial input, Modbus polling,
|
|
276
|
+
MQTT/STOMP messaging, AMQP consumption and confirmed publishing, and serialized
|
|
277
|
+
periodic ticks. SCADA composes these primitives with generic codecs, domain ports
|
|
278
|
+
and persistence adapters. Plant packages supply source mappings, payload formats,
|
|
279
|
+
queries, routing and plant-specific behavior.
|
|
280
|
+
|
|
281
|
+
The SCADA AMQP telemetry, MQTT relay and operation-sync pipelines use
|
|
282
|
+
`amqpSource`. It acknowledges after collector acceptance, requeues failures by
|
|
283
|
+
default, and avoids settling explicitly acknowledged or rejected deliveries twice.
|
|
284
|
+
Shutdown cancels consumption, drains accepted work and settlements, then closes
|
|
285
|
+
the channel and connection. Reconnect and restart wait for that drain.
|
|
286
|
+
`durableConsume` remains a compatibility facade over the shared AMQP session.
|
|
287
|
+
Collectors that batch records acknowledge acceptance into that collector; this is
|
|
288
|
+
not a guarantee that a later batch has already reached persistent storage.
|
|
289
|
+
|
|
290
|
+
`intervalSource` schedules operator-cache synchronization, stale-open-segment
|
|
291
|
+
sweeps, silent-stream reconciliation, PostgreSQL metric streams and the shared
|
|
292
|
+
ClickHouse stream hub. Polls do not overlap; metric streams await subscribers
|
|
293
|
+
before advancing their cursors. HTTP keepalive timers and domain coalescing
|
|
294
|
+
windows remain owned by their respective adapters and domain policies.
|
|
295
|
+
|
|
272
296
|
Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local path, in downstream packages.
|
|
273
297
|
|
|
274
298
|
## CI/CD
|
|
@@ -327,3 +351,47 @@ The RabbitMQ definitions builder and management reconciler are exported as
|
|
|
327
351
|
`@yarkivaev/scada/rabbitmq/definitions` and `@yarkivaev/scada/rabbitmq/reconcile`.
|
|
328
352
|
The caller supplies the vhost, connection settings and ownership policy for
|
|
329
353
|
retired routes. Reconciliation retains stored queues.
|
|
354
|
+
|
|
355
|
+
### Collect multiple OPC UA endpoints
|
|
356
|
+
|
|
357
|
+
`@yarkivaev/scada/opcua` exports `opcuaConnections(connections, publish, options)`
|
|
358
|
+
and `validateConnections(connections)`. The same entry point exports
|
|
359
|
+
`opcuaSource(connections, collector, options)` for source-to-sink composition;
|
|
360
|
+
the collector accepts `{ channel, value }`. SCADA owns the OPC UA runtime and
|
|
361
|
+
its `node-opcua` dependency; source-to-sink contains no OPC UA implementation.
|
|
362
|
+
Offline deployment planners should import
|
|
363
|
+
`validateConnections` from `@yarkivaev/scada/opcua/config`; this entry point never
|
|
364
|
+
loads the network library or its startup diagnostics. Each connection has a unique `url`,
|
|
365
|
+
positive `publishMs` and `heartbeatMs`, and `channels` with explicit, unique
|
|
366
|
+
`nodeId` values. Additional channel metadata is opaque to this library.
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
import opcuaConnections from '@yarkivaev/scada/opcua';
|
|
370
|
+
|
|
371
|
+
const collector = opcuaConnections([
|
|
372
|
+
{ url: 'opc.tcp://192.0.2.10:4840', publishMs: 100, heartbeatMs: 1000,
|
|
373
|
+
channels: [{ nodeId: 'ns=1;s=temperature', topic: 'line/temperature' }] }
|
|
374
|
+
], (channel, value) => publish(channel.topic, value));
|
|
375
|
+
await collector.start();
|
|
376
|
+
// On process shutdown:
|
|
377
|
+
await collector.stop();
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Each endpoint owns its client, session and subscription. An unavailable endpoint
|
|
381
|
+
retries independently while others continue. Start schedules these workers; it
|
|
382
|
+
does not wait for every server to be available. Samples are delivered only with
|
|
383
|
+
Good quality, both on change and through serialized fresh heartbeat reads.
|
|
384
|
+
The caller owns value conversion, routing, timestamps and destination transport.
|
|
385
|
+
Options `retryMs`, `connectTimeoutMs` and `requestTimeoutMs` bound retry timing and
|
|
386
|
+
network requests; `onError(error, connection)` receives connection diagnostics.
|
|
387
|
+
Do not replicate a collector with the same sources to distribute load: assign
|
|
388
|
+
disjoint source sets to separate instances instead.
|
|
389
|
+
|
|
390
|
+
A release can replace old collectors using
|
|
391
|
+
`retirements: [{ workload: 'deployment/old-reader', selector: 'app=old-reader' }]`.
|
|
392
|
+
For apply/adopt, the executor validates the new manifests first, foreground-deletes
|
|
393
|
+
only these explicit Deployments, and verifies that no selected old pods remain
|
|
394
|
+
before applying the replacement. This trades a collection gap for no overlap.
|
|
395
|
+
The caller must bind each selector to its old Deployment; no namespace-wide prune
|
|
396
|
+
is performed. Render/check/diff do not delete anything. An optional process `image`
|
|
397
|
+
overrides the release image, so unrelated collectors can retain their pinned runtime.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.116",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"./startTestEdgeApi": "./src/infrastructure/http/edge/startTestEdgeApi.js",
|
|
16
16
|
"./package.json": "./package.json",
|
|
17
17
|
"./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
|
|
18
|
-
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js"
|
|
18
|
+
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js",
|
|
19
|
+
"./opcua": "./src/infrastructure/opcua/index.js",
|
|
20
|
+
"./opcua/config": "./src/infrastructure/opcua/config.js"
|
|
19
21
|
},
|
|
20
22
|
"bin": {
|
|
21
23
|
"modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
|
|
@@ -33,8 +35,9 @@
|
|
|
33
35
|
"@clickhouse/client": "^1.0.0",
|
|
34
36
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
35
37
|
"@yarkivaev/simple-server": "1.0.2",
|
|
36
|
-
"@yarkivaev/source-to-sink": "1.3.
|
|
38
|
+
"@yarkivaev/source-to-sink": "1.3.11",
|
|
37
39
|
"amqplib": "^0.10.9",
|
|
40
|
+
"node-opcua": "2.179.0",
|
|
38
41
|
"pg": "^8.0.0",
|
|
39
42
|
"stompit": "^1.0.0",
|
|
40
43
|
"zod": "^4.6.7"
|
|
@@ -23,7 +23,7 @@ function grouped(machines, describe) {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* describe(row) returns a process descriptor with a name, command and optional
|
|
26
|
-
* env and
|
|
26
|
+
* env, resources and image. All sources describing the same process must agree.
|
|
27
27
|
* project(descriptor, rows) supplies adapter-specific env and optional hostDevices.
|
|
28
28
|
* A physical device may appear in many source rows but belongs to only one process.
|
|
29
29
|
*/
|
|
@@ -37,6 +37,7 @@ export default function collectorProcesses(machines, describe, project) {
|
|
|
37
37
|
devices.add(device);
|
|
38
38
|
});
|
|
39
39
|
return { name: descriptor.name, command: descriptor.command, env: { ...descriptor.env, ...settings.env },
|
|
40
|
-
hostDevices, resources: descriptor.resources
|
|
40
|
+
hostDevices, resources: descriptor.resources,
|
|
41
|
+
...(descriptor.image === undefined ? {} : { image: descriptor.image }) };
|
|
41
42
|
});
|
|
42
43
|
}
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
/** Retire only explicit Deployments with a single exact app label selector. */
|
|
4
|
+
function validateRetirements(plan) {
|
|
5
|
+
if (plan.retirements !== undefined && !Array.isArray(plan.retirements)) {
|
|
6
|
+
throw new Error('retirements must be an array');
|
|
7
|
+
}
|
|
8
|
+
for (const retirement of plan.retirements || []) {
|
|
9
|
+
const name = retirement?.workload?.match(/^deployment\/(?<name>[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/u)?.groups.name;
|
|
10
|
+
const label = retirement?.selector?.match(/^app=(?<label>[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)$/u)?.groups.label;
|
|
11
|
+
if (!name || name.length > 253 || name.split('.').some((part) => { return !part || part.length > 63; })
|
|
12
|
+
|| !label || label.length > 63 || (plan.workloads || []).includes(retirement.workload)) {
|
|
13
|
+
throw new Error('retirement requires an exact old deployment and app label outside desired workloads');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
/** Validates the target before helm or kubectl can fall back to a default. */
|
|
4
19
|
function validate(plan, action) {
|
|
5
20
|
if (!['render', 'check', 'diff', 'apply', 'adopt', 'check-adoption'].includes(action)) {
|
|
6
21
|
throw new Error('action must be render, check, diff or apply (adopt/check-adoption for migration)');
|
|
7
22
|
}
|
|
8
23
|
const fields = action === 'render' ? ['chart', 'release', 'namespace'] : ['chart', 'release', 'namespace', 'context', 'fieldManager'];
|
|
24
|
+
validateRetirements(plan);
|
|
9
25
|
fields.forEach((key) => {
|
|
10
26
|
if (typeof plan[key] !== 'string' || !plan[key].trim()) { throw new Error(`release requires ${key}`); }
|
|
11
27
|
});
|
|
@@ -13,9 +29,9 @@ function validate(plan, action) {
|
|
|
13
29
|
|
|
14
30
|
/** Every Kubernetes command, including the post-rollout hook, shares this target. */
|
|
15
31
|
function kubeAt(plan, execute) {
|
|
16
|
-
return (args, input) => {
|
|
32
|
+
return (args, input, capture = false) => {
|
|
17
33
|
return execute('kubectl', ['--context', plan.context, '--namespace', plan.namespace, ...args],
|
|
18
|
-
{ input, encoding: 'utf8', stdio: ['pipe', 'inherit', 'inherit'] });
|
|
34
|
+
{ input, encoding: 'utf8', stdio: ['pipe', capture ? 'pipe' : 'inherit', 'inherit'] });
|
|
19
35
|
};
|
|
20
36
|
}
|
|
21
37
|
|
|
@@ -25,8 +41,31 @@ function applyWith(kube, manifests, flags, checkOnly) {
|
|
|
25
41
|
if (!checkOnly) { kube(args, manifests); }
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
/** A missing Deployment can still have terminating pods from an interrupted attempt. */
|
|
45
|
+
function retireWorkloads(plan, kube) {
|
|
46
|
+
for (const { workload, selector } of plan.retirements || []) {
|
|
47
|
+
if (kube(['get', workload, '--ignore-not-found', '-o', 'name'], undefined, true).trim()) {
|
|
48
|
+
kube(['delete', workload, '--cascade=foreground', '--wait=true', '--timeout=300s']);
|
|
49
|
+
}
|
|
50
|
+
if (kube(['get', 'pods', '-l', selector, '-o', 'name'], undefined, true).trim()) {
|
|
51
|
+
throw new Error(`retirement pods remain for ${workload} (${selector})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function preflightRetirements(plan, action, kube, manifests) {
|
|
57
|
+
if (['apply', 'adopt'].includes(action) && plan.retirements?.length) {
|
|
58
|
+
if (action === 'adopt') {
|
|
59
|
+
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], true);
|
|
60
|
+
}
|
|
61
|
+
applyWith(kube, manifests, ['--server-side', `--field-manager=${plan.fieldManager}`], true);
|
|
62
|
+
retireWorkloads(plan, kube);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
28
66
|
/** Adopt legacy fields, then enforce the complete manifest before waiting for rollouts. */
|
|
29
67
|
function applyRelease(plan, action, kube, manifests) {
|
|
68
|
+
preflightRetirements(plan, action, kube, manifests);
|
|
30
69
|
if (['adopt', 'check-adoption'].includes(action)) {
|
|
31
70
|
applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], action === 'check-adoption');
|
|
32
71
|
if (action === 'check-adoption') { return ''; }
|
|
@@ -44,7 +83,10 @@ function applyRelease(plan, action, kube, manifests) {
|
|
|
44
83
|
* Renders a Helm release and executes the selected kubectl action synchronously.
|
|
45
84
|
* Supply chart, release, namespace and values. Cluster actions additionally require
|
|
46
85
|
* an explicit context and fieldManager. prerequisites and workloads are kubectl
|
|
47
|
-
* resource/name references.
|
|
86
|
+
* resource/name references. Optional retirements are { workload: "deployment/name",
|
|
87
|
+
* selector: "app=label" }; callers bind these to the old declarative app. They are
|
|
88
|
+
* removed after preflight and before any writes, with no selected pods remaining.
|
|
89
|
+
* afterReady(plan, kube) runs after a successful apply.
|
|
48
90
|
*/
|
|
49
91
|
export default function executeRelease(plan, action = 'render', execute = execFileSync) {
|
|
50
92
|
validate(plan, action);
|
|
@@ -1,169 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function connectBroker(url) {
|
|
4
|
-
return amqp.connect(url, { heartbeat: 10 });
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function scheduleLater(fn, pause) {
|
|
8
|
-
return setTimeout(fn, pause);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function clearLater(handle) {
|
|
12
|
-
clearTimeout(handle);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function reportFailure(error) {
|
|
16
|
-
process.stderr.write(`amqp consume failed ${error.message}\n`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function nextDelay(delay) {
|
|
20
|
-
return Math.min(delay * 2, 30000);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function blankState() {
|
|
24
|
-
return {
|
|
25
|
-
generation: 0,
|
|
26
|
-
timer: undefined,
|
|
27
|
-
stopping: false,
|
|
28
|
-
delay: 1000,
|
|
29
|
-
session: undefined
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function running(state) {
|
|
34
|
-
return Boolean(state.session) || (state.generation > 0 && !state.stopping);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function scheduleRetry(ctx, generation) {
|
|
38
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const pause = ctx.state.delay;
|
|
42
|
-
ctx.state.delay = nextDelay(ctx.state.delay);
|
|
43
|
-
ctx.state.timer = ctx.schedule(() => {
|
|
44
|
-
ctx.state.timer = undefined;
|
|
45
|
-
return ctx.open(generation);
|
|
46
|
-
}, pause);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function bindClose(ctx, generation, live) {
|
|
50
|
-
let dropped = false;
|
|
51
|
-
function lose() {
|
|
52
|
-
if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
dropped = true;
|
|
56
|
-
if (ctx.state.session === live) {
|
|
57
|
-
ctx.state.session = undefined;
|
|
58
|
-
}
|
|
59
|
-
scheduleRetry(ctx, generation);
|
|
60
|
-
}
|
|
61
|
-
live.connection.on('error', lose);
|
|
62
|
-
live.connection.on('close', lose);
|
|
63
|
-
live.channel.on('error', lose);
|
|
64
|
-
live.channel.on('close', lose);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function accept(ctx, generation, connection) {
|
|
68
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
69
|
-
await connection.close();
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
return connection.createChannel();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function keep(ctx, generation, connection, channel, tag) {
|
|
76
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
77
|
-
await channel.close();
|
|
78
|
-
await connection.close();
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
const live = { connection, channel, tag };
|
|
82
|
-
ctx.state.session = live;
|
|
83
|
-
ctx.state.delay = 1000;
|
|
84
|
-
bindClose(ctx, generation, live);
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function open(ctx, generation) {
|
|
89
|
-
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const connection = await ctx.connect(ctx.url);
|
|
94
|
-
const channel = await accept(ctx, generation, connection);
|
|
95
|
-
if (!channel) {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const tag = await ctx.subscribe(channel);
|
|
99
|
-
await keep(ctx, generation, connection, channel, tag);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
ctx.report(error);
|
|
102
|
-
scheduleRetry(ctx, generation);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function begin(ctx) {
|
|
107
|
-
if (running(ctx.state)) {
|
|
108
|
-
return Promise.resolve();
|
|
109
|
-
}
|
|
110
|
-
ctx.state.stopping = false;
|
|
111
|
-
ctx.state.generation += 1;
|
|
112
|
-
ctx.state.delay = 1000;
|
|
113
|
-
return open(ctx, ctx.state.generation);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function end(ctx) {
|
|
117
|
-
ctx.state.stopping = true;
|
|
118
|
-
ctx.state.generation += 1;
|
|
119
|
-
if (ctx.state.timer) {
|
|
120
|
-
ctx.clear(ctx.state.timer);
|
|
121
|
-
ctx.state.timer = undefined;
|
|
122
|
-
}
|
|
123
|
-
const live = ctx.state.session;
|
|
124
|
-
ctx.state.session = undefined;
|
|
125
|
-
if (!live) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
await live.channel.cancel(live.tag);
|
|
129
|
-
await live.channel.close();
|
|
130
|
-
await live.connection.close();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Keeps one AMQP consumer subscribed across broker restarts.
|
|
135
|
-
*
|
|
136
|
-
* @param {string} url - RabbitMQ AMQP URL
|
|
137
|
-
* @param {function} subscribe - async (channel) => consumerTag
|
|
138
|
-
* @param {object} [options] - connect, schedule, clear, report overrides
|
|
139
|
-
* @returns {object} session with start and stop
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* const session = durableConsume('amqp://localhost', async (channel) => {
|
|
143
|
-
* const tag = await channel.consume('scada.telemetry.ingest', () => {});
|
|
144
|
-
* return tag.consumerTag;
|
|
145
|
-
* });
|
|
146
|
-
* await session.start();
|
|
147
|
-
*/
|
|
148
|
-
export default function durableConsume(url, subscribe, options = {}) {
|
|
149
|
-
const ctx = {
|
|
150
|
-
url,
|
|
151
|
-
subscribe,
|
|
152
|
-
connect: options.connect || connectBroker,
|
|
153
|
-
schedule: options.schedule || scheduleLater,
|
|
154
|
-
clear: options.clear || clearLater,
|
|
155
|
-
report: options.report || reportFailure,
|
|
156
|
-
state: blankState(),
|
|
157
|
-
open(generation) {
|
|
158
|
-
return open(ctx, generation);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
return {
|
|
162
|
-
start() {
|
|
163
|
-
return begin(ctx);
|
|
164
|
-
},
|
|
165
|
-
stop() {
|
|
166
|
-
return end(ctx);
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
}
|
|
1
|
+
// Compatibility facade; transport lifecycle belongs to source-to-sink.
|
|
2
|
+
export { amqpSession as default } from '@yarkivaev/source-to-sink';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clock } from '@yarkivaev/source-to-sink';
|
|
1
|
+
import { clock, intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Whether a stream is past the silence budget.
|
|
@@ -83,15 +83,12 @@ function gateState(config) {
|
|
|
83
83
|
intervalSec: interval,
|
|
84
84
|
sources,
|
|
85
85
|
clk: config.clock || clock(),
|
|
86
|
-
delay: config.delay ||
|
|
87
|
-
|
|
88
|
-
}),
|
|
89
|
-
clear: config.clear || clearInterval,
|
|
86
|
+
delay: config.delay || setTimeout,
|
|
87
|
+
clear: config.clear || clearTimeout,
|
|
90
88
|
lastSeen: new Map(),
|
|
91
89
|
held: new Map(),
|
|
92
90
|
live: new Set(),
|
|
93
91
|
collector: undefined,
|
|
94
|
-
timer: undefined,
|
|
95
92
|
running: false
|
|
96
93
|
};
|
|
97
94
|
}
|
|
@@ -119,6 +116,9 @@ export default function silentStreams(config) {
|
|
|
119
116
|
function runPulse() {
|
|
120
117
|
pulse(state);
|
|
121
118
|
}
|
|
119
|
+
const ticker = intervalSource(state.intervalSec * 1000, { accept: runPulse }, {
|
|
120
|
+
schedule: state.delay, clear: state.clear
|
|
121
|
+
});
|
|
122
122
|
return {
|
|
123
123
|
seen(name, at = state.clk.millis()) {
|
|
124
124
|
state.lastSeen.set(name, at);
|
|
@@ -134,16 +134,14 @@ export default function silentStreams(config) {
|
|
|
134
134
|
};
|
|
135
135
|
},
|
|
136
136
|
start() {
|
|
137
|
+
if (state.running) { return; }
|
|
137
138
|
state.running = true;
|
|
138
139
|
runPulse();
|
|
139
|
-
|
|
140
|
+
void ticker.start();
|
|
140
141
|
},
|
|
141
142
|
stop() {
|
|
142
143
|
state.running = false;
|
|
143
|
-
|
|
144
|
-
state.clear(state.timer);
|
|
145
|
-
state.timer = undefined;
|
|
146
|
-
}
|
|
144
|
+
void ticker.stop();
|
|
147
145
|
for (const name of [...state.live]) {
|
|
148
146
|
stopOne(state, name);
|
|
149
147
|
}
|
|
@@ -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,4 @@
|
|
|
1
|
-
import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
2
|
-
import durableConsume from '../durableConsume.js';
|
|
1
|
+
import { amqpSource, batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
3
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
4
3
|
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
5
4
|
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
@@ -20,7 +19,7 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
|
20
19
|
onSeen(name);
|
|
21
20
|
}
|
|
22
21
|
}
|
|
23
|
-
codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
22
|
+
return codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -50,16 +49,6 @@ export function telemetryConsumer(codec, channel, onSeen) {
|
|
|
50
49
|
* @param {object} [options] - batch and prefetch options
|
|
51
50
|
* @returns {object} ingest with start and stop
|
|
52
51
|
*/
|
|
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
52
|
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
64
53
|
const prefetch = options.prefetch || 32;
|
|
65
54
|
const size = options.size || 100;
|
|
@@ -70,11 +59,11 @@ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
|
70
59
|
const breaker = circuit(threshold, timeout, clk);
|
|
71
60
|
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
72
61
|
const codec = metricsCodec(collector);
|
|
73
|
-
const session =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
62
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
63
|
+
accept(delivery) {
|
|
64
|
+
return acceptTelemetryDeliver(codec, delivery.fields, delivery.content, options.onSeen);
|
|
65
|
+
}
|
|
66
|
+
}, { ...options, prefetch });
|
|
78
67
|
return {
|
|
79
68
|
start() {
|
|
80
69
|
return session.start();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { amqpSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,24 +34,14 @@ export function relayConsumer(sink, channel) {
|
|
|
34
34
|
* @param {object} [options] - prefetch count
|
|
35
35
|
* @returns {object} Relay with start and stop
|
|
36
36
|
*/
|
|
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
37
|
export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
48
38
|
const prefetch = options.prefetch || 32;
|
|
49
39
|
let opened = false;
|
|
50
|
-
const session =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
40
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
41
|
+
accept(delivery) {
|
|
42
|
+
return sink.write([deliverToMqttRecord(delivery.fields, delivery.content)]);
|
|
43
|
+
}
|
|
44
|
+
}, { ...options, prefetch });
|
|
55
45
|
return {
|
|
56
46
|
start() {
|
|
57
47
|
if (!opened) {
|
|
@@ -62,7 +52,8 @@ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
|
62
52
|
},
|
|
63
53
|
async stop() {
|
|
64
54
|
await session.stop();
|
|
65
|
-
sink.stop();
|
|
55
|
+
await sink.stop();
|
|
56
|
+
opened = false;
|
|
66
57
|
}
|
|
67
58
|
};
|
|
68
59
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function positive(value, name) {
|
|
2
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
3
|
+
throw new TypeError(`${name} must be a positive number`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
function validateChannels(channels) {
|
|
7
|
+
if (!Array.isArray(channels) || channels.length === 0) {
|
|
8
|
+
throw new TypeError('channels must be a nonempty array');
|
|
9
|
+
}
|
|
10
|
+
const nodes = new Set();
|
|
11
|
+
channels.forEach(({ nodeId }) => {
|
|
12
|
+
if (typeof nodeId !== 'string' || !nodeId.trim() || nodes.has(nodeId)) {
|
|
13
|
+
throw new TypeError('invalid or duplicate nodeId');
|
|
14
|
+
}
|
|
15
|
+
nodes.add(nodeId);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validUrl(value) {
|
|
20
|
+
const url = new URL(value);
|
|
21
|
+
return url.protocol === 'opc.tcp:' && Boolean(url.hostname) && !url.username && !url.password;
|
|
22
|
+
}
|
|
23
|
+
export function validateConnections(connections) {
|
|
24
|
+
if (!Array.isArray(connections)) {
|
|
25
|
+
throw new TypeError('connections must be an array');
|
|
26
|
+
}
|
|
27
|
+
const urls = new Set();
|
|
28
|
+
connections.forEach((connection) => {
|
|
29
|
+
if (typeof connection.url !== 'string' || !validUrl(connection.url) || urls.has(connection.url)) {
|
|
30
|
+
throw new TypeError('invalid or duplicate OPC UA URL');
|
|
31
|
+
}
|
|
32
|
+
urls.add(connection.url);
|
|
33
|
+
positive(connection.publishMs, 'publishMs');
|
|
34
|
+
positive(connection.heartbeatMs, 'heartbeatMs');
|
|
35
|
+
validateChannels(connection.channels);
|
|
36
|
+
});
|
|
37
|
+
return connections;
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { OPCUAClient } from 'node-opcua';
|
|
2
|
+
import session from './session.js';
|
|
3
|
+
|
|
4
|
+
function report(options, error, connection) {
|
|
5
|
+
try {
|
|
6
|
+
options.onError?.(error, connection);
|
|
7
|
+
} catch {
|
|
8
|
+
// Observability callbacks must not break endpoint recovery.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function delay(state, milliseconds) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
state.wake = resolve;
|
|
14
|
+
state.timer = setTimeout(resolve, milliseconds);
|
|
15
|
+
}).finally(() => { clearTimeout(state.timer); });
|
|
16
|
+
}
|
|
17
|
+
function clientFor(options) {
|
|
18
|
+
return OPCUAClient.create({ endpointMustExist: false, keepSessionAlive: false,
|
|
19
|
+
connectionStrategy: { maxRetry: 0 }, transportTimeout: options.connectTimeoutMs,
|
|
20
|
+
defaultTransactionTimeout: options.requestTimeoutMs, requestedSessionTimeout: options.requestTimeoutMs * 2 });
|
|
21
|
+
}
|
|
22
|
+
function disconnection(client) {
|
|
23
|
+
let pending;
|
|
24
|
+
return () => {
|
|
25
|
+
pending ??= client.disconnect();
|
|
26
|
+
return pending;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function attempt(state, connection, publish, options) {
|
|
30
|
+
const client = clientFor(options);
|
|
31
|
+
const close = disconnection(client);
|
|
32
|
+
state.close = close;
|
|
33
|
+
const current = { active: true, connection, wake: null, timer: null, pending: new Set() };
|
|
34
|
+
state.current = current;
|
|
35
|
+
try {
|
|
36
|
+
await client.connect(connection.url);
|
|
37
|
+
if (state.active) {
|
|
38
|
+
await session(current, client, connection, publish, options);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (state.active) {
|
|
42
|
+
report(options, error, connection);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
current.active = false;
|
|
46
|
+
try { await close(); } finally { await Promise.all(current.pending); }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function run(state, connection, publish, options) {
|
|
50
|
+
while (state.active) {
|
|
51
|
+
// Each endpoint deliberately serializes attempts and heartbeat reads.
|
|
52
|
+
// eslint-disable-next-line no-await-in-loop
|
|
53
|
+
await attempt(state, connection, publish, options);
|
|
54
|
+
if (state.active) {
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop
|
|
56
|
+
await delay(state, options.retryMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export default function endpoint(connection, publish, options) {
|
|
61
|
+
const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
|
|
62
|
+
return {
|
|
63
|
+
start() {
|
|
64
|
+
if (!state.active) {
|
|
65
|
+
state.active = true;
|
|
66
|
+
state.task = run(state, connection, publish, options);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async stop() {
|
|
70
|
+
state.active = false;
|
|
71
|
+
if (state.current) {
|
|
72
|
+
state.current.active = false;
|
|
73
|
+
state.current.wake?.();
|
|
74
|
+
}
|
|
75
|
+
state.wake?.();
|
|
76
|
+
await state.close?.();
|
|
77
|
+
await state.task;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import opcuaSource from './source.js';
|
|
2
|
+
export { validateConnections } from './config.js';
|
|
3
|
+
|
|
4
|
+
/** Compatibility adapter for callback-based SCADA consumers. */
|
|
5
|
+
export default function opcuaConnections(connections, publish, options = {}) {
|
|
6
|
+
if (typeof publish !== 'function') { throw new TypeError('publish must be a function'); }
|
|
7
|
+
return opcuaSource(connections, { accept: ({ channel, value }) => { return publish(channel, value); } }, options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { default as opcuaSource } from './source.js';
|
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
18
|
+
const source = intervalSource(step, { accept: fn });
|
|
19
|
+
void source.start();
|
|
18
20
|
return () => {
|
|
19
|
-
|
|
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.
|
|
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.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
watcher
|
|
89
|
-
|
|
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
|
|
10
|
+
const source = intervalSource(step, { async accept() {
|
|
10
11
|
try {
|
|
11
12
|
const rows = await read.poll(topic, lastTs.toISOString(), time().toISOString());
|
|
12
|
-
rows.
|
|
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
|
-
}
|
|
22
|
+
} });
|
|
23
|
+
void source.start();
|
|
21
24
|
return {
|
|
22
25
|
cancel() {
|
|
23
|
-
|
|
26
|
+
return source.stop();
|
|
24
27
|
}
|
|
25
28
|
};
|
|
26
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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 =
|
|
61
|
-
|
|
62
|
-
|
|
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();
|