@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 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.115",
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.10",
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 resources. All sources describing the same process must agree.
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. afterReady(plan, kube) runs after a successful apply.
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
- import amqp from 'amqplib';
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 || ((fn, ms) => {
87
- return setInterval(fn, ms);
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
- state.timer = state.delay(runPulse, state.intervalSec * 1000);
140
+ void ticker.start();
140
141
  },
141
142
  stop() {
142
143
  state.running = false;
143
- if (state.timer !== undefined) {
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
- const gate = { busy: false };
22
- let timer;
23
- let active = false;
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 = durableConsume(
74
- amqpUrl,
75
- subscribeMetrics(queue, prefetch, codec, options.onSeen),
76
- options
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 durableConsume from '../durableConsume.js';
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 = durableConsume(
51
- amqpUrl,
52
- subscribeRelay(queue, prefetch, sink),
53
- options
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
- 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();