@yarkivaev/scada 2.3.116 → 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 (95) hide show
  1. package/README.md +90 -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 +11 -5
  6. package/src/application/deploy/readDocument.js +4 -1
  7. package/src/application/plantServer.js +1 -1
  8. package/src/application/runtime/additionalAdapters.js +139 -0
  9. package/src/application/runtime/assignment.js +30 -0
  10. package/src/application/runtime/budgetedSink.js +17 -0
  11. package/src/application/runtime/collectorSignals.js +14 -0
  12. package/src/application/runtime/compositeLifecycle.js +49 -0
  13. package/src/application/runtime/createRegistry.js +20 -0
  14. package/src/application/runtime/deadline.js +8 -0
  15. package/src/application/runtime/index.js +10 -0
  16. package/src/application/runtime/manifest.js +38 -0
  17. package/src/application/runtime/pipelineAdapters.js +60 -0
  18. package/src/application/runtime/resolveSecrets.js +12 -0
  19. package/src/application/runtime/resourceClaims.js +21 -0
  20. package/src/application/runtime/runCollector.js +50 -0
  21. package/src/application/runtime/runtime.js +86 -0
  22. package/src/application/runtime/runtimeHealth.js +29 -0
  23. package/src/application/runtime/runtimeServices.js +15 -0
  24. package/src/application/runtime/stateDocument.js +22 -0
  25. package/src/application/runtime/streamPorts.js +42 -0
  26. package/src/application/runtime/streamTask.js +105 -0
  27. package/src/application/runtime/taskBudget.js +31 -0
  28. package/src/application/runtime/taskContext.js +18 -0
  29. package/src/application/runtime/taskQueue.js +21 -0
  30. package/src/application/runtime/taskSlot.js +46 -0
  31. package/src/application/runtime/taskState.js +73 -0
  32. package/src/application/siteCollection.js +14 -0
  33. package/src/application/siteServer.js +7 -6
  34. package/src/application/siteTelemetry.js +1 -1
  35. package/src/application/supervisorSink.js +9 -8
  36. package/src/application/sync/siteSyncBind.js +1 -1
  37. package/src/application/sync/siteSyncTargets.js +1 -1
  38. package/src/bin/collector.js +7 -0
  39. package/src/bin/modbus-to-mqtt.js +11 -9
  40. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  41. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  42. package/src/infrastructure/ingest/durableConsume.js +2 -2
  43. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  44. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  45. package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
  46. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  47. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  48. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  49. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  50. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  51. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  52. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  53. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  54. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  55. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
  56. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
  57. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  58. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  59. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  60. package/src/infrastructure/opcua/certificates.js +11 -0
  61. package/src/infrastructure/opcua/delivery.js +38 -0
  62. package/src/infrastructure/opcua/endpoint.js +12 -2
  63. package/src/infrastructure/opcua/session.js +20 -10
  64. package/src/infrastructure/opcua/source.js +6 -3
  65. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  66. package/src/infrastructure/streams/README.md +291 -0
  67. package/src/infrastructure/streams/abortable.js +14 -0
  68. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  69. package/src/infrastructure/streams/amqpSink/session.js +96 -0
  70. package/src/infrastructure/streams/amqpSink.js +47 -0
  71. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  72. package/src/infrastructure/streams/amqpSource.js +97 -0
  73. package/src/infrastructure/streams/brokerClose.js +19 -0
  74. package/src/infrastructure/streams/budgetAccept.js +6 -0
  75. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  76. package/src/infrastructure/streams/deadline.js +1 -0
  77. package/src/infrastructure/streams/follow.js +41 -0
  78. package/src/infrastructure/streams/index.js +15 -0
  79. package/src/infrastructure/streams/lokiSource.js +88 -0
  80. package/src/infrastructure/streams/modbusConnection.js +173 -0
  81. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  82. package/src/infrastructure/streams/modbusSource.js +185 -0
  83. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  84. package/src/infrastructure/streams/mqttSink.js +54 -0
  85. package/src/infrastructure/streams/mqttSource.js +66 -0
  86. package/src/infrastructure/streams/pollingSource.js +1 -0
  87. package/src/infrastructure/streams/positive.js +5 -0
  88. package/src/infrastructure/streams/postgresSink.js +124 -0
  89. package/src/infrastructure/streams/serialReceive.js +80 -0
  90. package/src/infrastructure/streams/serialSource.js +87 -0
  91. package/src/infrastructure/streams/sqliteSink.js +55 -0
  92. package/src/infrastructure/streams/stompBody.js +46 -0
  93. package/src/infrastructure/streams/stompSend.js +89 -0
  94. package/src/infrastructure/streams/stompSource.js +201 -0
  95. package/src/infrastructure/sync/operationSyncIngest.js +1 -1
@@ -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,4 +1,5 @@
1
- import { amqpSource, batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
1
+ import { amqpSource } from '../../streams/index.js';
2
+ import ingestBatch from '../ingestBatch.js';
2
3
  import deliverToMqttRecord from './deliverToMqttRecord.js';
3
4
  import metricsCodec from '../mqtt/metricsTransformer.js';
4
5
  import streamNameFromTopic from './streamNameFromTopic.js';
@@ -33,10 +34,14 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
33
34
  export function telemetryConsumer(codec, channel, onSeen) {
34
35
  return (msg) => {
35
36
  if (!msg) {
36
- 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); });
37
42
  }
38
- acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
39
43
  channel.ack(msg);
44
+ return undefined;
40
45
  };
41
46
  }
42
47
 
@@ -51,13 +56,7 @@ export function telemetryConsumer(codec, channel, onSeen) {
51
56
  */
52
57
  export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
53
58
  const prefetch = options.prefetch || 32;
54
- const size = options.size || 100;
55
- const interval = options.interval || 5;
56
- const threshold = options.threshold || 5;
57
- const timeout = options.timeout || 60;
58
- const clk = clock();
59
- const breaker = circuit(threshold, timeout, clk);
60
- const collector = timedBatch(batch(sink, size, breaker), interval);
59
+ const collector = ingestBatch(sink, options);
61
60
  const codec = metricsCodec(collector);
62
61
  const session = amqpSource(amqpUrl, queue, {
63
62
  accept(delivery) {
@@ -70,7 +69,7 @@ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
70
69
  },
71
70
  async stop() {
72
71
  await session.stop();
73
- collector.stop();
72
+ await collector.stop();
74
73
  }
75
74
  };
76
75
  }
@@ -1,4 +1,4 @@
1
- import { amqpSource } from '@yarkivaev/source-to-sink';
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();
@@ -43,10 +47,10 @@ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
43
47
  }
44
48
  }, { ...options, prefetch });
45
49
  return {
46
- start() {
50
+ async start() {
47
51
  if (!opened) {
52
+ await sink.start();
48
53
  opened = true;
49
- sink.start();
50
54
  }
51
55
  return session.start();
52
56
  },
@@ -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';
@@ -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
+ 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
+ }
@@ -1,4 +1,6 @@
1
1
  import { OPCUAClient } from 'node-opcua';
2
+ import { setMaxListeners } from 'node:events';
3
+ import delivery from './delivery.js';
2
4
  import session from './session.js';
3
5
 
4
6
  function report(options, error, connection) {
@@ -26,11 +28,17 @@ function disconnection(client) {
26
28
  return pending;
27
29
  };
28
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
+ }
29
37
  async function attempt(state, connection, publish, options) {
30
38
  const client = clientFor(options);
31
39
  const close = disconnection(client);
32
40
  state.close = close;
33
- const current = { active: true, connection, wake: null, timer: null, pending: new Set() };
41
+ const current = attemptState(connection, options);
34
42
  state.current = current;
35
43
  try {
36
44
  await client.connect(connection.url);
@@ -58,18 +66,20 @@ async function run(state, connection, publish, options) {
58
66
  }
59
67
  }
60
68
  export default function endpoint(connection, publish, options) {
69
+ const send = delivery(publish, options);
61
70
  const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
62
71
  return {
63
72
  start() {
64
73
  if (!state.active) {
65
74
  state.active = true;
66
- state.task = run(state, connection, publish, options);
75
+ state.task = run(state, connection, send, options);
67
76
  }
68
77
  },
69
78
  async stop() {
70
79
  state.active = false;
71
80
  if (state.current) {
72
81
  state.current.active = false;
82
+ state.current.controller.abort();
73
83
  state.current.wake?.();
74
84
  }
75
85
  state.wake?.();
@@ -1,20 +1,30 @@
1
1
  import { checkSubscription, watchSubscription } from './subscriptionHealth.js';
2
2
  import { AttributeIds, TimestampsToReturn } from 'node-opcua';
3
3
 
4
- function deliver(state, channel, value, publish, options) {
5
- if (state.active && !state.failure && value.statusCode.isGood()) {
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)) {
6
19
  const accepted = Promise.resolve().then(() => {
7
20
  if (state.active && !state.failure) {
8
- return publish(channel, value.value.value);
21
+ return publish(channel, value.value.value, { signal: state.controller.signal });
9
22
  }
10
23
  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); });
24
+ }).catch((error) => { report(state, options, error); }).finally(() => {
25
+ state.pending.delete(accepted);
26
+ state.overflow = false;
27
+ });
18
28
  state.pending.add(accepted);
19
29
  }
20
30
  }
@@ -3,12 +3,13 @@ export { validateConnections } from './config.js';
3
3
 
4
4
  /** Independent OPC UA endpoints delivering opaque channel metadata and values. */
5
5
  export default function opcuaSource(connections, collector, options = {}) {
6
- const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, ...options };
6
+ const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, deliveryTimeoutMs: 5000, maxPending: 100, maxPendingBytes: 1048576, ...options };
7
7
  validateConnections(connections);
8
8
  if (!collector || typeof collector.accept !== 'function') {
9
9
  throw new TypeError('Collector must have an accept() method');
10
10
  }
11
- ['retryMs', 'connectTimeoutMs', 'requestTimeoutMs'].forEach((name) => { positive(settings[name], name); });
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'); }
12
13
  let endpoints;
13
14
  let stopping = Promise.resolve();
14
15
  let revision = 0;
@@ -19,9 +20,11 @@ export default function opcuaSource(connections, collector, options = {}) {
19
20
  await stopping;
20
21
  // Importing the package or validating a factory never loads node-opcua.
21
22
  const { default: endpoint } = await import('./endpoint.js');
23
+ const { default: certificates } = await import('./certificates.js');
24
+ await certificates();
22
25
  if (requested === revision) {
23
26
  endpoints ??= connections.map((connection) => {
24
- return endpoint(connection, (channel, value) => { return collector.accept({ channel, value }); }, settings);
27
+ return endpoint(connection, (channel, value, context) => { return collector.accept({ channel, value }, context); }, settings);
25
28
  });
26
29
  endpoints.forEach((item) => { item.start(); });
27
30
  }
@@ -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
+ }
@@ -0,0 +1,291 @@
1
+ # SCADA stream adapters
2
+
3
+ Protocol and storage adapters are exported by `@yarkivaev/scada/streams`.
4
+ Generic batching, circuit and periodic sources are imported from `@yarkivaev/source-to-sink`.
5
+
6
+ ## Components
7
+
8
+ | Component | Description |
9
+ |-----------|-------------|
10
+ | `batch(sink, size, circuit)` | Collects records and flushes to sink when size is reached |
11
+ | `circuit(threshold, timeout, clock)` | Circuit breaker for failure isolation |
12
+ | `timedBatch(collector, interval)` | Adds time-based auto-flush to a collector |
13
+ | `clock()` | System time provider |
14
+ | `pollingSource(fetch, interval, collector, clock)` | Generic polling source with time window |
15
+ | `clickhouseSink(url, table)` | ClickHouse sink adapter |
16
+ | `mqttSource(url, topic, collector)` | MQTT subscription source |
17
+ | `lokiSource(url, query, interval, collector, clock)` | Loki polling source |
18
+
19
+ ### STOMP delivery window
20
+
21
+ `stompSource(url, destination, collector, { prefetch: 32 })` bounds broker deliveries
22
+ that are waiting for acknowledgement. The default is 32; use a positive safe
23
+ integer to tune throughput. A zero (unlimited) window is rejected because a
24
+ backlog can otherwise exhaust the consumer's heap. With `manualAck: true`, the
25
+ collector remains responsible for settling frames. If it flushes only by count,
26
+ its batch must not exceed the prefetch window; a timed flush also avoids stalls.
27
+
28
+ Manual collectors may call `settle.nack(false)` to dead-letter invalid messages. Configure a durable dead-letter queue and broker policy before enabling this; without a route RabbitMQ discards rejected messages. `settle.nack()` retains retry behaviour.
29
+
30
+
31
+ ## Transport sources and lifecycle
32
+
33
+ The sources below receive a collector with `accept(record)`. Return a promise
34
+ that resolves when processing is accepted; rejecting it reports a processing
35
+ failure. Keep application decoding, routing, SQL queries and cursor updates in
36
+ the collector. Await `stop()` before stopping downstream collectors or sinks,
37
+ so in-flight acceptance can finish. A collector that never settles also prevents
38
+ a draining stop from finishing.
39
+
40
+ ### Serial chunks
41
+
42
+ ```javascript
43
+ import { serialSource } from '@yarkivaev/scada/streams';
44
+
45
+ const source = serialSource([
46
+ { path: '/dev/ttyUSB0', baudRate: 9600, dataBits: 8, parity: 'none', stopBits: 1 }
47
+ ], {
48
+ accept({ device, chunk }) {
49
+ return decodeAndStore(device.path, chunk);
50
+ }
51
+ }, { retryMs: 1000, onError: (error, device) => console.error(device.path, error) });
52
+
53
+ await source.start();
54
+ ```
55
+
56
+ Each port has its own open/retry loop. `chunk` is the original buffer and `device`
57
+ is the original configuration object; framing and decoding belong in the
58
+ collector. Duplicate paths are rejected. Errors or a closed port trigger cleanup
59
+ and retry without interrupting other devices. `start()` initiates opens;
60
+ `stop()` waits for pending opens, closes ports, removes listeners and drains
61
+ in-flight acceptance before restart.
62
+
63
+ For testing or another port implementation, pass `openPort(device)`, returning a
64
+ port or promise for a port. The port supports EventEmitter `data`, `error` and
65
+ `close` events plus `close(callback)`. The default opens `serialport` lazily.
66
+ Serial input is a push stream; the source does not impose a collector backlog
67
+ limit or pause the device.
68
+
69
+ ### Periodic collection
70
+
71
+ ```javascript
72
+ import { intervalSource } from '@yarkivaev/source-to-sink';
73
+
74
+ const source = intervalSource(1000, {
75
+ async accept({ ts }) {
76
+ const records = await readChanges(ts);
77
+ await publish(records);
78
+ // Advance a cursor only after publication succeeds.
79
+ }
80
+ }, { immediate: true, onError: console.error });
81
+
82
+ await source.start();
83
+ ```
84
+
85
+ `intervalMs` must be positive. Ticks carry a Unix timestamp in milliseconds.
86
+ The default `immediate: false` waits one interval before the first tick. With
87
+ `immediate: true`, `start()` waits for the first acceptance to settle. Subsequent
88
+ ticks wait the interval after the previous acceptance settles, so slow work never
89
+ overlaps another tick. Rejections call `onError` and scheduling continues.
90
+ `stop()` cancels scheduling and drains the active tick; restart waits for that
91
+ drain. Tests can inject `schedule(callback, milliseconds)` and `clear(handle)`.
92
+
93
+ ### AMQP consumption
94
+
95
+ ```javascript
96
+ import { amqpSource } from '@yarkivaev/scada/streams';
97
+
98
+ const source = amqpSource('amqp://localhost', 'readings', {
99
+ async accept(delivery) {
100
+ await storeReading(JSON.parse(delivery.content.toString()));
101
+ }
102
+ }, {
103
+ queueOptions: { durable: true },
104
+ exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
105
+ bindings: [{ exchange: 'telemetry', pattern: 'reading.#' }],
106
+ prefetch: 32,
107
+ onError: console.error
108
+ });
109
+
110
+ await source.start();
111
+ ```
112
+
113
+ Deliveries contain `content`, `fields`, `properties`, `ack()` and
114
+ `reject(requeue = true)`. By default the source acknowledges only after
115
+ `collector.accept` resolves; rejection requeues the delivery and reports the
116
+ error. Settlement is idempotent within a delivery. Set `ackMode: 'manual'` to
117
+ let the collector choose `ack()` or `reject(false)` for dead-lettering; configure
118
+ a broker dead-letter route if those messages must be retained. A collector that
119
+ settles manually should still await its processing work so stop can drain it.
120
+
121
+ The default queue is durable and the default prefetch is 32. Choose a finite,
122
+ positive prefetch to bound outstanding broker deliveries. With count-based
123
+ batching, the batch must fit within that window, or use a timed flush.
124
+ Connection/channel failure or consumer cancellation causes reconnection with
125
+ backoff from one to 30 seconds. `stop()` cancels the consumer, drains acceptance,
126
+ and closes the channel and connection. `active()` means the lifecycle is running,
127
+ including while reconnecting; it does not prove a broker connection is ready.
128
+
129
+ `amqpSession(url, subscribeChannel, options)` exposes that same lifecycle for
130
+ custom channel topology or consumption. The async `subscribeChannel(channel)`
131
+ returns a consumer tag for cancellation. `options.drain(channel)` can await
132
+ application work before channel closure. Both APIs accept `onError` (or `report`),
133
+ `connect(url)`, `schedule(callback, milliseconds)` and `clear(handle)`; the latter
134
+ three are useful for testing. The default connection uses a ten-second heartbeat.
135
+
136
+ ### AMQP publication
137
+
138
+ ```javascript
139
+ import { amqpSink } from '@yarkivaev/scada/streams';
140
+
141
+ const sink = amqpSink('amqp://localhost', {
142
+ exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
143
+ maxPending: 1024,
144
+ onError: console.error
145
+ });
146
+ await sink.start();
147
+ await sink.write([{
148
+ exchange: 'telemetry', routingKey: 'reading.temperature',
149
+ body: JSON.stringify({ value: 42 }), options: { persistent: true }
150
+ }]);
151
+ await sink.stop();
152
+ ```
153
+
154
+ `write(records)` resolves after broker publisher confirms for every record. Each
155
+ record requires an exchange, routing key and string or Buffer body; `options`
156
+ passes publication options to amqplib. A confirm does not mean a consumer has
157
+ processed the message or that an unroutable message reached a queue.
158
+
159
+ `maxPending` (default 1024) bounds outstanding confirmations. Writes reject while
160
+ the connection is unavailable, when the channel awaits its `drain` event, or when
161
+ the batch would exceed that bound. There is no unbounded offline queue. A write
162
+ can partially succeed before rejection; callers must account for that when
163
+ choosing retries or advancing cursors.
164
+
165
+ Connection loss or `confirmTimeoutMs` expiry rejects uncertain publications and
166
+ reconnects for future writes. **The sink never automatically replays records**:
167
+ the broker may already have accepted them. Use application idempotency when
168
+ retrying uncertain results. `active()` reports whether a live channel exists.
169
+ `start()` waits for connection and topology setup; a failed initial attempt
170
+ rejects while background retries continue until stopped.
171
+
172
+ Other options are `retryMs` (1000), `connectTimeoutMs` (5000),
173
+ `confirmTimeoutMs` (10000), `onError(error)`, and injectable
174
+ `connect(url, connectionOptions)`. Exchange declarations default to type `topic`
175
+ and durable options when omitted. `stop()` closes the session and rejects pending
176
+ confirms; it does not flush them. For graceful shutdown, first stop and drain
177
+ sources, await outstanding `write()` promises, then stop the sink.
178
+
179
+ ## Database sink ownership
180
+
181
+ `clickhouseSink(url, table, options)` owns its client; await its idempotent
182
+ `stop()` to close the client's sockets. `write(records, { signal })` forwards
183
+ cancellation to the installed ClickHouse client's native `abort_signal` API.
184
+ `postgresSink.stop()` ends only a pool created by the sink. An `options.pool`
185
+ pool remains caller-owned and open; explicit transaction clients remain borrowed.
186
+ Both stops have a `stopTimeoutMs` deadline (5000). PostgreSQL rejects already
187
+ aborted writes but does not claim to cancel an in-flight query through AbortSignal.
188
+ Stop/drain sources before closing their owned database sink.
189
+
190
+ ## Bounded operations and shutdown
191
+
192
+ Await lifecycle methods and writes. In particular, `mqttSink.start()` now waits
193
+ for MQTT CONNACK, and `mqttSink.write(records, { signal })` returns a promise.
194
+ QoS 1/2 waits for the MQTT.js broker-confirmation callback; QoS 0 only confirms
195
+ local handoff and is **not a durable acceptance boundary**. MQTT has no offline
196
+ write queue or automatic replay. Connection loss, cancellation, or a missing
197
+ confirmation retires that session; await `stop()` before starting a fresh session.
198
+ Defaults: `maxPending: 1024`, `maxPendingBytes: 1048576`,
199
+ `connectTimeoutMs: 5000`, `confirmTimeoutMs: 10000`, `stopTimeoutMs: 5000`.
200
+
201
+ `mqttSource` defers PUBACK/PUBCOMP until asynchronous `collector.accept()` finishes.
202
+ This only represents durability if the collector's promise represents durable
203
+ storage. MQTT.js serializes this acceptance hook: use a source-facing batch size
204
+ of one (or flush each acceptance), since a batch requiring future MQTT messages
205
+ cannot fill while the current message waits for durable acknowledgement. Its MQTT 5 receive window defaults to 32 and packet limit to 1 MiB
206
+ (`prefetch`, `maxPacketBytes`). Sources forward an optional second acceptance
207
+ argument `{ signal }`; existing one-argument collectors remain compatible.
208
+
209
+ `boundedBatch(sink, options)` provides bounded asynchronous acceptance. Options
210
+ are `size: 100`, `maxRecords: 1000`, `maxBytes: 1048576`, `maxWaitMs: 1000`, and
211
+ `writeTimeoutMs: 10000`. Acceptance resolves after its sink write; admission
212
+ rejects at capacity. Explicit `flush()`/`stop()` reports failures from the drain
213
+ it awaits. A timed-out write aborts its signal and permanently retires the batch.
214
+ A batch rejection can represent a partially applied external write: retries and
215
+ idempotency belong to the caller.
216
+
217
+ Serial reception pauses the stream while acceptance runs and bounds queued
218
+ chunks/bytes (`maxPending: 32`, `maxPendingBytes: 1048576`). Custom ports must
219
+ implement `pause`/`resume` for flow control; overflow is reported through
220
+ `onError` and permanently retires the source (including a rejected `stop()`).
221
+ There is no implicit drop-and-retry policy. Hardware bytes that arrive despite flow control cannot be recovered
222
+ by this library. `openTimeoutMs` and `stopTimeoutMs` default to 5000;
223
+ `acceptTimeoutMs` defaults to 10000. Timed-out lifecycle operations quarantine
224
+ the instance. RTU buses serialize each slave read and acceptance, recover from
225
+ individual rejected reads, and close late connections without polling them.
226
+ RTU options include `readTimeoutMs: 5000`, `connectTimeoutMs: 5000`,
227
+ `acceptTimeoutMs: 10000`, and `stopTimeoutMs: 5000`.
228
+
229
+ Periodic sources use `acceptTimeoutMs: 10000` and `stopTimeoutMs: 5000`.
230
+ Polling advances its cursor only after all returned rows are accepted and fences
231
+ late fetch results after stop. Fetch receives `(since, until, { signal })`.
232
+
233
+ Loki accepts a sixth options argument: `requestTimeoutMs: 5000`,
234
+ `maxResponseBytes: 1048576`, `pageLimit: 1000`, `queryLagMs: 1000`, and `onError`.
235
+ HTTP body reads are capped and aborted on cancellation/deadline. A response at
236
+ or above the page limit rejects before delivery or cursor advance; this API does
237
+ not claim pagination or a portable historical checkpoint. Query windows lag the
238
+ wall clock to allow ingestion visibility. Entries appearing later than the
239
+ configured lag can still be missed; this is a bounded-lag live source.
240
+
241
+
242
+ AMQP sources validate `prefetch` (32), `maxMessageBytes` (1048576), and
243
+ `maxPendingBytes` (prefetch times maxMessageBytes). Oversized or aggregate-overbudget
244
+ payloads are rejected without requeue before calling the collector and reported
245
+ through `onError`. Broker delivery decoding still allocates the incoming message;
246
+ configure broker message-size limits as well for hostile inputs.
247
+
248
+ AMQP cleanup attempts cancellation, draining, channel close, and connection
249
+ close even if an earlier stage hangs. Each cleanup stage uses `stopTimeoutMs`
250
+ (default 5000); the total source cleanup budget can therefore be four times
251
+ that value. Uncertain cleanup/connect deadlines prevent automatic reuse. Native
252
+ AMQP socket destruction is attempted on cleanup timeout. AMQP writes accept
253
+ `{ signal }` and reject pending confirms on cancellation. A confirmed AMQP
254
+ publication still does not prove business processing. Set `record.options.mandatory`
255
+ to require routing: a correlated broker return rejects with `AMQP_UNROUTABLE`
256
+ even when the broker also confirms the publish. Supplied `messageId` values stay
257
+ unchanged for consumer deduplication. The reserved header
258
+ `x-source-to-sink-publish-id` is overwritten with a unique per-publication token
259
+ for return correlation; all other supplied headers are preserved. Callers still
260
+ own poison-message policies.
261
+
262
+ `stompSend` now waits for the broker SEND receipt and graceful disconnect. Its
263
+ `timeoutMs` (10000) bounds the whole operation and destroys the native client on
264
+ timeout. A receipt is not proof of business processing. `stompSource.stop()`
265
+ returns a promise, revokes late ACKs, and bounds acceptance draining with
266
+ `stopTimeoutMs` (5000). Read/accept waits use `acceptTimeoutMs` (10000). Native STOMP frame streams are
267
+ limited before concatenation by `maxMessageBytes` (1048576); `maxPendingBytes`
268
+ (default prefetch times maxMessageBytes) caps bodies held by actual acceptance.
269
+ Oversized/read-failed frames retire the channel; shared budget leases remain
270
+ held through actual acceptance, including after timeout.
271
+ The upstream STOMP Channel API initiates close without exposing a transport-close
272
+ completion callback; source shutdown proves callback revocation/draining, not
273
+ physical socket closure. Manual ACK collectors remain responsible for settlement
274
+ and poison-message policy.
275
+
276
+ AMQP, MQTT, serial, STOMP sources, and `boundedBatch` accept an optional shared `budget` with synchronous
277
+ `reserve({ bytes, records: 1 })`, returning an idempotent release function or
278
+ throwing on capacity. Source leases cover actual acceptance; batch leases include queued records and
279
+ remain held until the actual sink write settles. Leases release on completion
280
+ or rejection, including never-written queued records when a batch retires. A timed-out collector that has not actually settled
281
+ keeps its reservation; creating another source cannot reclaim those retained
282
+ bytes. These counters bound retained payload admission, not native decoder or
283
+ transport heap allocations.
284
+
285
+ A deadline bounds waiting; it does not prove an already-sent external operation
286
+ was cancelled. Callers must await shutdown, treat deadline rejection as an
287
+ uncertain resource/outcome, and quarantine it rather than immediately reuse it.
288
+
289
+ ## License
290
+
291
+ MIT
@@ -0,0 +1,14 @@
1
+ /** Cancels the driver and revokes its result; callers still own resource cleanup. */
2
+ export default function abortable(promise, signal, cancel) {
3
+ if (!signal) { return promise; }
4
+ const state = {};
5
+ function abort() {
6
+ const error = signal.reason || new Error('Operation aborted');
7
+ try { cancel(); } catch (failure) { state.reject(failure); return; }
8
+ state.reject(error);
9
+ }
10
+ const stopped = new Promise((resolve, reject) => { state.reject = reject; });
11
+ signal.addEventListener('abort', abort, { once: true });
12
+ if (signal.aborted) { abort(); }
13
+ return Promise.race([promise, stopped]).finally(() => { signal.removeEventListener('abort', abort); });
14
+ }