@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
@@ -0,0 +1,11 @@
1
+ import { getDefaultCertificateManager } from 'node-opcua';
2
+
3
+ let initialization;
4
+ /** node-opcua's shared manager is not safe to initialize concurrently on a cold PKI. */
5
+ export default function certificates() {
6
+ initialization ??= getDefaultCertificateManager('PKI').initialize().catch((error) => {
7
+ initialization = undefined;
8
+ throw error;
9
+ });
10
+ return initialization;
11
+ }
@@ -0,0 +1,38 @@
1
+ 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,38 @@
1
+ function reservation(pending, bytes, size, options) {
2
+ if (pending.size >= options.maxPending || bytes + size > options.maxPendingBytes) {
3
+ throw new Error('OPC UA delivery capacity exceeded; live value dropped');
4
+ }
5
+ return options.budget?.reserve({ bytes: size, records: 1 }) ?? (() => { return undefined; });
6
+ }
7
+ /** Deadline bounds the caller; capacity stays reserved until the actual write settles. */
8
+ export default function delivery(publish, options) {
9
+ const pending = new Set();
10
+ let bytes = 0;
11
+ return async (channel, value, context = {}) => {
12
+ const size = Buffer.byteLength(JSON.stringify({ channel, value }));
13
+ const releaseBudget = reservation(pending, bytes, size, options);
14
+ const controller = new AbortController();
15
+ function abort() { controller.abort(context.signal.reason); }
16
+ context.signal?.addEventListener('abort', abort, { once: true });
17
+ const actual = Promise.resolve().then(() => {
18
+ if (context.signal?.aborted) { abort(); }
19
+ controller.signal.throwIfAborted();
20
+ return publish(channel, value, { signal: controller.signal });
21
+ });
22
+ pending.add(actual);
23
+ bytes += size;
24
+ function release() { pending.delete(actual); bytes -= size; releaseBudget(); }
25
+ actual.then(release, release);
26
+ let timer;
27
+ const deadline = new Promise((resolve, reject) => {
28
+ timer = setTimeout(() => {
29
+ controller.abort(new Error('OPC UA delivery deadline exceeded; outcome uncertain'));
30
+ reject(controller.signal.reason);
31
+ }, options.deliveryTimeoutMs);
32
+ });
33
+ return await Promise.race([actual, deadline]).finally(() => {
34
+ clearTimeout(timer);
35
+ context.signal?.removeEventListener('abort', abort);
36
+ });
37
+ };
38
+ }
@@ -0,0 +1,90 @@
1
+ import { OPCUAClient } from 'node-opcua';
2
+ import { setMaxListeners } from 'node:events';
3
+ import delivery from './delivery.js';
4
+ import session from './session.js';
5
+
6
+ function report(options, error, connection) {
7
+ try {
8
+ options.onError?.(error, connection);
9
+ } catch {
10
+ // Observability callbacks must not break endpoint recovery.
11
+ }
12
+ }
13
+ function delay(state, milliseconds) {
14
+ return new Promise((resolve) => {
15
+ state.wake = resolve;
16
+ state.timer = setTimeout(resolve, milliseconds);
17
+ }).finally(() => { clearTimeout(state.timer); });
18
+ }
19
+ function clientFor(options) {
20
+ return OPCUAClient.create({ endpointMustExist: false, keepSessionAlive: false,
21
+ connectionStrategy: { maxRetry: 0 }, transportTimeout: options.connectTimeoutMs,
22
+ defaultTransactionTimeout: options.requestTimeoutMs, requestedSessionTimeout: options.requestTimeoutMs * 2 });
23
+ }
24
+ function disconnection(client) {
25
+ let pending;
26
+ return () => {
27
+ pending ??= client.disconnect();
28
+ return pending;
29
+ };
30
+ }
31
+ function attemptState(connection, options) {
32
+ const controller = new AbortController();
33
+ // Each admitted delivery registers one cancellation listener on this bounded fan-out.
34
+ setMaxListeners(options.maxPending + 1, controller.signal);
35
+ return { active: true, connection, wake: null, timer: null, pending: new Set(), controller };
36
+ }
37
+ async function attempt(state, connection, publish, options) {
38
+ const client = clientFor(options);
39
+ const close = disconnection(client);
40
+ state.close = close;
41
+ const current = attemptState(connection, options);
42
+ state.current = current;
43
+ try {
44
+ await client.connect(connection.url);
45
+ if (state.active) {
46
+ await session(current, client, connection, publish, options);
47
+ }
48
+ } catch (error) {
49
+ if (state.active) {
50
+ report(options, error, connection);
51
+ }
52
+ } finally {
53
+ current.active = false;
54
+ try { await close(); } finally { await Promise.all(current.pending); }
55
+ }
56
+ }
57
+ async function run(state, connection, publish, options) {
58
+ while (state.active) {
59
+ // Each endpoint deliberately serializes attempts and heartbeat reads.
60
+ // eslint-disable-next-line no-await-in-loop
61
+ await attempt(state, connection, publish, options);
62
+ if (state.active) {
63
+ // eslint-disable-next-line no-await-in-loop
64
+ await delay(state, options.retryMs);
65
+ }
66
+ }
67
+ }
68
+ export default function endpoint(connection, publish, options) {
69
+ const send = delivery(publish, options);
70
+ const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
71
+ return {
72
+ start() {
73
+ if (!state.active) {
74
+ state.active = true;
75
+ state.task = run(state, connection, send, options);
76
+ }
77
+ },
78
+ async stop() {
79
+ state.active = false;
80
+ if (state.current) {
81
+ state.current.active = false;
82
+ state.current.controller.abort();
83
+ state.current.wake?.();
84
+ }
85
+ state.wake?.();
86
+ await state.close?.();
87
+ await state.task;
88
+ }
89
+ };
90
+ }
@@ -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,70 @@
1
+ import { checkSubscription, watchSubscription } from './subscriptionHealth.js';
2
+ import { AttributeIds, TimestampsToReturn } from 'node-opcua';
3
+
4
+ function report(state, options, error) {
5
+ try { options.onError?.(error, state.connection); } catch {
6
+ // Callback isolation.
7
+ }
8
+ }
9
+ function admitted(state, options) {
10
+ if (state.pending.size < options.maxPending) { return true; }
11
+ if (!state.overflow) {
12
+ state.overflow = true;
13
+ report(state, options, new Error('OPC UA notification capacity exceeded; live value dropped'));
14
+ }
15
+ return false;
16
+ }
17
+ export function deliver(state, channel, value, publish, options) {
18
+ if (state.active && !state.failure && value.statusCode.isGood() && admitted(state, options)) {
19
+ const accepted = Promise.resolve().then(() => {
20
+ if (state.active && !state.failure) {
21
+ return publish(channel, value.value.value, { signal: state.controller.signal });
22
+ }
23
+ return undefined;
24
+ }).catch((error) => { report(state, options, error); }).finally(() => {
25
+ state.pending.delete(accepted);
26
+ state.overflow = false;
27
+ });
28
+ state.pending.add(accepted);
29
+ }
30
+ }
31
+ async function monitors(subscription, state, connection, publish, options) {
32
+ await Promise.all(connection.channels.map(async (channel) => {
33
+ const item = await subscription.monitor({ nodeId: channel.nodeId, attributeId: AttributeIds.Value },
34
+ { samplingInterval: connection.publishMs, discardOldest: true, queueSize: 10 }, TimestampsToReturn.Both);
35
+ if (!item.statusCode.isGood()) {
36
+ throw new Error(`Cannot monitor ${channel.nodeId}: ${item.statusCode.toString()}`);
37
+ }
38
+ item.on('changed', (value) => { deliver(state, channel, value, publish, options); });
39
+ }));
40
+ }
41
+ function pause(state, milliseconds) {
42
+ return new Promise((resolve) => {
43
+ state.wake = resolve;
44
+ state.timer = setTimeout(resolve, milliseconds);
45
+ }).finally(() => { clearTimeout(state.timer); });
46
+ }
47
+ async function heartbeat(state, activeSession, connection, publish, options) {
48
+ while (state.active) {
49
+ checkSubscription(state, options);
50
+ // Fresh reads are serialized; a slow read cannot overlap the next heartbeat.
51
+ // eslint-disable-next-line no-await-in-loop
52
+ await pause(state, connection.heartbeatMs);
53
+ if (state.active) {
54
+ checkSubscription(state, options);
55
+ // eslint-disable-next-line no-await-in-loop
56
+ const values = await activeSession.read(connection.channels.map(({ nodeId }) => { return { nodeId, attributeId: AttributeIds.Value }; }), 0);
57
+ values.forEach((value, index) => { deliver(state, connection.channels[index], value, publish, options); });
58
+ }
59
+ }
60
+ }
61
+ export default async function session(state, client, connection, publish, options) {
62
+ const activeSession = await client.createSession();
63
+ if (state.active) {
64
+ const subscription = await activeSession.createSubscription2({ requestedPublishingInterval: connection.publishMs,
65
+ requestedLifetimeCount: 30, requestedMaxKeepAliveCount: 3, maxNotificationsPerPublish: 0, publishingEnabled: true, priority: 0 });
66
+ watchSubscription(state, subscription);
67
+ await monitors(subscription, state, connection, publish, options);
68
+ await heartbeat(state, activeSession, connection, publish, options);
69
+ }
70
+ }
@@ -0,0 +1,38 @@
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, deliveryTimeoutMs: 5000, maxPending: 100, maxPendingBytes: 1048576, ...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', 'deliveryTimeoutMs', 'maxPending', 'maxPendingBytes'].forEach((name) => { positive(settings[name], name); });
12
+ if (!Number.isSafeInteger(settings.maxPending)) { throw new TypeError('maxPending must be a safe integer'); }
13
+ let endpoints;
14
+ let stopping = Promise.resolve();
15
+ let revision = 0;
16
+ return {
17
+ async start() {
18
+ revision += 1;
19
+ const requested = revision;
20
+ await stopping;
21
+ // Importing the package or validating a factory never loads node-opcua.
22
+ const { default: endpoint } = await import('./endpoint.js');
23
+ const { default: certificates } = await import('./certificates.js');
24
+ await certificates();
25
+ if (requested === revision) {
26
+ endpoints ??= connections.map((connection) => {
27
+ return endpoint(connection, (channel, value, context) => { return collector.accept({ channel, value }, context); }, settings);
28
+ });
29
+ endpoints.forEach((item) => { item.start(); });
30
+ }
31
+ },
32
+ stop() {
33
+ revision += 1;
34
+ stopping = Promise.all((endpoints ?? []).map((item) => { return item.stop(); }));
35
+ return stopping;
36
+ }
37
+ };
38
+ }
@@ -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
  }
@@ -0,0 +1,24 @@
1
+ /** Atomic state compare-and-swap; distributed ownership must be integrated separately. */
2
+ export default function taskStatePg(pool, { maxBytes = 1048576 } = {}) {
3
+ return {
4
+ async load(taskId) {
5
+ const result = await pool.query('SELECT version, document FROM collector_task_state WHERE task_id = $1', [taskId]);
6
+ if (!result.rows.length) { return null; }
7
+ return { ...result.rows[0].document, version: Number(result.rows[0].version) };
8
+ },
9
+ async commit(input) {
10
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0) { throw new TypeError('Invalid expectedVersion'); }
11
+ if (input.ownership !== undefined) { throw new Error('This store has no external ownership integration'); }
12
+ const { expectedVersion } = input;
13
+ const document = { taskId: input.taskId, stateVersion: input.stateVersion, compatibilityKey: input.compatibilityKey,
14
+ checkpoint: input.checkpoint, pending: input.pending };
15
+ const json = JSON.stringify(document);
16
+ if (Buffer.byteLength(json) > maxBytes) { throw new Error('Task state capacity exceeded'); }
17
+ const result = expectedVersion === 0
18
+ ? await pool.query('INSERT INTO collector_task_state (task_id, version, document) VALUES ($1, 1, $2::jsonb) ON CONFLICT DO NOTHING RETURNING version', [input.taskId, json])
19
+ : await pool.query('UPDATE collector_task_state SET version = version + 1, document = $2::jsonb, updated_at = now() WHERE task_id = $1 AND version = $3 RETURNING version', [input.taskId, json, expectedVersion]);
20
+ if (!result.rows.length) { throw new Error('Task state conflict'); }
21
+ return Number(result.rows[0].version);
22
+ }
23
+ };
24
+ }