@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.
- package/README.md +158 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +14 -5
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +90 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +70 -0
- package/src/infrastructure/opcua/source.js +38 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- 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
|
+
}
|