@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,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
+ }
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ function publishOptions(record) {
4
+ const options = record.options || {};
5
+ if (!options.mandatory) { return options; }
6
+ const headers = { ...options.headers, 'x-source-to-sink-publish-id': randomUUID() };
7
+ return { ...options, headers };
8
+ }
9
+ /** Confirms one send, without replaying an outcome the broker may already have accepted. */
10
+ export default function publication(live, record, timeoutMs, signal) {
11
+ if (!record || typeof record.exchange !== 'string' || typeof record.routingKey !== 'string'
12
+ || !(typeof record.body === 'string' || Buffer.isBuffer(record.body))) {
13
+ return Promise.reject(new TypeError('AMQP record requires exchange, routingKey and string/Buffer body'));
14
+ }
15
+ if (signal?.aborted) { return Promise.reject(signal.reason || new Error('AMQP publish aborted')); }
16
+ const options = publishOptions(record);
17
+ return new Promise((resolve, reject) => {
18
+ const body = Buffer.isBuffer(record.body) ? record.body : Buffer.from(record.body);
19
+ let settled = false;
20
+ function abort() { live.fail(signal.reason || new Error('AMQP publish aborted')); }
21
+ const timer = { handle: null };
22
+ function settle(error) {
23
+ if (!settled) {
24
+ settled = true;
25
+ clearTimeout(timer.handle);
26
+ signal?.removeEventListener('abort', abort);
27
+ live.pending.delete(settle);
28
+ if (options.mandatory) { live.returns.delete(options.headers['x-source-to-sink-publish-id']); }
29
+ if (error) { reject(error); } else { resolve(); }
30
+ }
31
+ }
32
+ timer.handle = setTimeout(() => {
33
+ const error = new Error('AMQP publish confirmation timed out');
34
+ settle(error);
35
+ live.fail(error);
36
+ }, timeoutMs);
37
+ live.pending.add(settle);
38
+ if (options.mandatory) { live.returns.set(options.headers['x-source-to-sink-publish-id'], settle); }
39
+ signal?.addEventListener('abort', abort, { once: true });
40
+ try {
41
+ if (!live.channel.publish(record.exchange, record.routingKey, body, options, settle)) {
42
+ live.blocked = true;
43
+ }
44
+ } catch (error) { settle(error); }
45
+ });
46
+ }
@@ -0,0 +1,96 @@
1
+ import amqp from 'amqplib';
2
+ import brokerClose, { destroyConnection } from '../brokerClose.js';
3
+ import deadline from '../deadline.js';
4
+
5
+ export function report(state, error) {
6
+ try { state.options.onError?.(error); } catch {
7
+ // Diagnostics cannot break recovery.
8
+ }
9
+ }
10
+ export function closeLive(live) {
11
+ if (!live) { return Promise.resolve(); }
12
+ live.closing ??= Promise.resolve().then(async () => {
13
+ live.pending.forEach((settle) => { settle(new Error('AMQP connection closed before confirmation')); });
14
+ await brokerClose([
15
+ () => { return live.channel?.close(); }, () => { return live.connection?.close(); }
16
+ ], live.options.stopTimeoutMs, 'AMQP close', () => { destroyConnection(live.connection); });
17
+ });
18
+ return live.closing;
19
+ }
20
+ function retry(state, epoch) {
21
+ if (state.wanted && state.epoch === epoch && !state.retry) {
22
+ state.retry = setTimeout(() => {
23
+ state.retry = null;
24
+ void state.reopen().catch((error) => { report(state, error); });
25
+ }, state.options.retryMs);
26
+ }
27
+ }
28
+ function drop(state, live, error) {
29
+ if (live.dead) { return; }
30
+ live.dead = true;
31
+ live.pending.forEach((settle) => { settle(error || new Error('AMQP connection closed')); });
32
+ if (state.live === live) { state.live = null; }
33
+ if (error) { report(state, error); }
34
+ void closeLive(live).then(() => { retry(state, live.epoch); }, (failure) => { state.fatal = failure; report(state, failure); });
35
+ }
36
+ function bind(state, live, handle) {
37
+ handle.on('error', (error) => { drop(state, live, error); });
38
+ handle.on('close', () => { drop(state, live); });
39
+ }
40
+ function returned(live, message) {
41
+ const settle = live.returns.get(message.properties?.headers?.['x-source-to-sink-publish-id']);
42
+ const error = new Error(`AMQP publication returned unroutable: ${message.fields?.replyText || 'NO_ROUTE'}`);
43
+ error.code = 'AMQP_UNROUTABLE';
44
+ if (settle) { settle(error); } else { live.fail(error); }
45
+ }
46
+ async function configure(state, live) {
47
+ // This attempt owns live; stop closes it after opening settles and epoch is checked below.
48
+ // eslint-disable-next-line require-atomic-updates
49
+ live.channel = await live.connection.createConfirmChannel();
50
+ bind(state, live, live.channel);
51
+ live.channel.on('return', (message) => { returned(live, message); });
52
+ live.channel.on('drain', () => { live.blocked = false; });
53
+ await Promise.all((state.options.exchanges || []).map((item) => {
54
+ return live.channel.assertExchange(item.name, item.type || 'topic', item.options || {durable: true});
55
+ }));
56
+ }
57
+ async function current(state, live) {
58
+ if (live.epoch !== state.epoch || !state.wanted) {
59
+ await closeLive(live);
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+ function keep(state, live) {
65
+ if (live.epoch !== state.epoch || !state.wanted) { return closeLive(live); }
66
+ state.live = live;
67
+ return undefined;
68
+ }
69
+ async function connect(state, epoch) {
70
+ const live = { options: state.options, connection: null, channel: null, pending: new Set(), returns: new Map(), epoch, dead: false, blocked: false,
71
+ fail(error) { drop(state, live, error); } };
72
+ try {
73
+ live.connection = await (state.options.connect || amqp.connect)(state.url, { timeout: state.options.connectTimeoutMs });
74
+ bind(state, live, live.connection);
75
+ if (!await current(state, live)) { return; }
76
+ await configure(state, live);
77
+ if (live.dead) { throw new Error('AMQP session closed during initialization'); }
78
+ await keep(state, live);
79
+ } catch (error) {
80
+ await closeLive(live);
81
+ retry(state, epoch);
82
+ throw error;
83
+ }
84
+ }
85
+ export function begin(state) {
86
+ if (state.fatal) { return Promise.reject(state.fatal); }
87
+ if (state.live) { return Promise.resolve(); }
88
+ if (state.opening) { return state.opening; }
89
+ if (!state.wanted) { return Promise.resolve(); }
90
+ clearTimeout(state.retry);
91
+ state.retry = null;
92
+ state.opening = deadline(connect(state, state.epoch), state.options.connectTimeoutMs, 'AMQP connect', (error) => {
93
+ state.fatal = error; state.wanted = false; state.epoch += 1;
94
+ }).finally(() => { state.opening = null; });
95
+ return state.opening;
96
+ }
@@ -0,0 +1,47 @@
1
+ import { begin, closeLive } from './amqpSink/session.js';
2
+ import publication from './amqpSink/publication.js';
3
+
4
+ function settings(url, options) {
5
+ if (typeof url !== 'string' || !/^amqps?:\/\//u.test(url)) { throw new TypeError('AMQP URL is required'); }
6
+ const config = { retryMs: 1000, connectTimeoutMs: 5000, confirmTimeoutMs: 10000, stopTimeoutMs: 5000, maxPending: 1024, ...options };
7
+ ['retryMs', 'connectTimeoutMs', 'confirmTimeoutMs', 'stopTimeoutMs', 'maxPending'].forEach((name) => {
8
+ if (!Number.isFinite(config[name]) || config[name] <= 0) { throw new TypeError(`${name} must be positive`); }
9
+ });
10
+ if (!Number.isInteger(config.maxPending)) { throw new TypeError('maxPending must be an integer'); }
11
+ return config;
12
+ }
13
+ function stop(state) {
14
+ state.request += 1;
15
+ state.epoch += 1;
16
+ state.wanted = false;
17
+ clearTimeout(state.retry);
18
+ state.retry = null;
19
+ const { live } = state;
20
+ state.live = null;
21
+ state.stopped = Promise.all([closeLive(live), state.opening?.catch(() => { return undefined; })]);
22
+ return state.stopped;
23
+ }
24
+ /** AMQP record sink with confirms and independent connection recovery; never automatically replays sends. */
25
+ export default function amqpSink(url, options = {}) {
26
+ const state = {url, options: settings(url, options), wanted: false, epoch: 0, request: 0,
27
+ live: null, opening: null, retry: null, stopped: Promise.resolve(), reopen() { return begin(state); }};
28
+ return {
29
+ active() { return Boolean(state.live); },
30
+ start() {
31
+ state.wanted = true;
32
+ state.request += 1;
33
+ const { request } = state;
34
+ return state.stopped.then(() => { if (request === state.request) { return begin(state); } return undefined; });
35
+ },
36
+ stop() { return stop(state); },
37
+ write(records, { signal } = {}) {
38
+ if (!Array.isArray(records)) { return Promise.reject(new TypeError('AMQP records must be an array')); }
39
+ if (!state.live) { return Promise.reject(new Error('AMQP sink is not connected')); }
40
+ const { live } = state;
41
+ if (live.blocked || live.pending.size + records.length > state.options.maxPending) {
42
+ return Promise.reject(new Error('AMQP sink backpressure: retry after confirmations or drain'));
43
+ }
44
+ return Promise.all(records.map((record) => { return publication(live, record, state.options.confirmTimeoutMs, signal); }));
45
+ }
46
+ };
47
+ }
@@ -0,0 +1,127 @@
1
+ // Each session is mutated only by its generation; disposal waits for subscription.
2
+ /* eslint-disable require-atomic-updates */
3
+ import brokerClose, { destroyConnection } from '../brokerClose.js';
4
+ import deadline from '../deadline.js';
5
+ import positive from '../positive.js';
6
+
7
+ async function connectBroker(url) {
8
+ const { default: amqp } = await import('amqplib');
9
+ return amqp.connect(url, { heartbeat: 10 });
10
+ }
11
+ function reportFailure(error) {
12
+ process.stderr.write(`amqp consume failed ${error.message}\n`);
13
+ }
14
+ async function closeLive(live, ctx) {
15
+ try {
16
+ await brokerClose([
17
+ () => { return live.tag ? live.channel.cancel(live.tag) : undefined; },
18
+ () => { return ctx.drain?.(live.channel); },
19
+ () => { return live.channel?.close(); },
20
+ () => { return live.connection?.close(); }
21
+ ], ctx.stopTimeoutMs, 'AMQP cleanup', () => { destroyConnection(live.connection); });
22
+ } catch (error) { ctx.state.fatal = error; throw error; }
23
+ }
24
+ function dispose(live, ctx) {
25
+ if (!live) { return Promise.resolve(); }
26
+ if (!live.closing) { live.closing = closeLive(live, ctx); }
27
+ return live.closing;
28
+ }
29
+
30
+ function current(ctx, generation) {
31
+ return !ctx.state.fatal && ctx.state.running && ctx.state.generation === generation;
32
+ }
33
+ function retry(ctx, generation) {
34
+ if (!current(ctx, generation) || ctx.state.timer !== undefined) { return; }
35
+ const pause = ctx.state.delay;
36
+ ctx.state.delay = Math.min(pause * 2, 30000);
37
+ ctx.state.timer = ctx.schedule(() => {
38
+ ctx.state.timer = undefined;
39
+ ctx.state.opening = deadline(ctx.open(generation), ctx.connectTimeoutMs, 'AMQP connect', (error) => {
40
+ ctx.state.fatal = error; ctx.state.running = false; ctx.state.generation += 1;
41
+ });
42
+ void ctx.state.opening.catch(ctx.report);
43
+ return ctx.state.opening;
44
+ }, pause);
45
+ }
46
+ function bind(ctx, generation, live, emitter) {
47
+ function lost(error) {
48
+ if (live.lost || !current(ctx, generation)) { return; }
49
+ live.lost = true;
50
+ if (error) { ctx.report(error); }
51
+ ctx.state.cleanup = live.ready.then(() => { return dispose(live, ctx); }).then(() => { retry(ctx, generation); });
52
+ void ctx.state.cleanup.catch(ctx.report);
53
+ }
54
+ emitter.on('error', lost);
55
+ emitter.on('close', lost);
56
+ }
57
+ async function subscribe(ctx, generation, live) {
58
+ if (!current(ctx, generation) || live.lost) { return; }
59
+ live.channel = await live.connection.createChannel();
60
+ bind(ctx, generation, live, live.channel);
61
+ if (!current(ctx, generation) || live.lost) { return; }
62
+ live.tag = await ctx.subscribe(live.channel);
63
+ }
64
+ async function open(ctx, generation) {
65
+ let complete;
66
+ const live = { closed: false, ready: new Promise((resolve) => { complete = resolve; }) };
67
+ try {
68
+ live.connection = await ctx.connect(ctx.url);
69
+ bind(ctx, generation, live, live.connection);
70
+ await subscribe(ctx, generation, live);
71
+ if (!current(ctx, generation) || live.lost) { await dispose(live, ctx); return; }
72
+ ctx.state.live = live;
73
+ ctx.state.delay = 1000;
74
+ } catch (error) {
75
+ ctx.report(error);
76
+ await dispose(live, ctx);
77
+ retry(ctx, generation);
78
+ } finally { complete(); }
79
+ }
80
+ async function begin(ctx) {
81
+ if (ctx.state.fatal) { throw ctx.state.fatal; }
82
+ if (ctx.state.running) { await ctx.state.opening; return; }
83
+ ctx.state.running = true;
84
+ ctx.state.generation += 1;
85
+ const { generation } = ctx.state;
86
+ await ctx.state.stopping;
87
+ await ctx.state.cleanup;
88
+ if (current(ctx, generation)) {
89
+ ctx.state.opening = deadline(ctx.open(generation), ctx.connectTimeoutMs, 'AMQP connect', (error) => {
90
+ ctx.state.fatal = error; ctx.state.running = false; ctx.state.generation += 1;
91
+ });
92
+ await ctx.state.opening;
93
+ }
94
+ }
95
+ async function end(ctx) {
96
+ ctx.state.running = false;
97
+ ctx.state.generation += 1;
98
+ if (ctx.state.timer !== undefined) { ctx.clear(ctx.state.timer); ctx.state.timer = undefined; }
99
+ await ctx.state.opening;
100
+ await ctx.state.cleanup;
101
+ await dispose(ctx.state.live, ctx);
102
+ ctx.state.live = undefined;
103
+ }
104
+ /** Shared AMQP session lifecycle; subscribe returns a consumer tag. */
105
+ export default function amqpSession(url, subscribeChannel, options = {}) {
106
+ positive(options.stopTimeoutMs ?? 5000, 'stopTimeoutMs');
107
+ positive(options.connectTimeoutMs ?? 5000, 'connectTimeoutMs');
108
+ const ctx = {
109
+ stopTimeoutMs: options.stopTimeoutMs ?? 5000, connectTimeoutMs: options.connectTimeoutMs ?? 5000,
110
+ url, subscribe: subscribeChannel, drain: options.drain,
111
+ connect: options.connect || connectBroker,
112
+ schedule: options.schedule || setTimeout,
113
+ clear: options.clear || clearTimeout,
114
+ report: options.onError || options.report || reportFailure,
115
+ state: { generation: 0, running: false, delay: 1000 },
116
+ open(generation) { return open(ctx, generation); }
117
+ };
118
+ return {
119
+ start() { return begin(ctx); },
120
+ stop() {
121
+ ctx.state.stopping = end(ctx);
122
+ return ctx.state.stopping;
123
+ },
124
+ generation() { return ctx.state.generation; },
125
+ active() { return !ctx.state.fatal && ctx.state.running; }
126
+ };
127
+ }