@yarkivaev/scada 2.3.115 → 2.4.0

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