@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,55 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SQLite sink for batch record insertion.
|
|
5
|
+
*
|
|
6
|
+
* Opens a SQLite database in WAL mode and implements the Sink
|
|
7
|
+
* interface for use with batch collectors. Creates the target
|
|
8
|
+
* table with the given columns if it does not exist.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const sink = sqliteSink('/tmp/metrics.db', 'metrics', ['topic', 'ts', 'value']);
|
|
12
|
+
* sink.write([{ topic: 'sensor/1', ts: 1700000000000, value: 42 }]);
|
|
13
|
+
*
|
|
14
|
+
* @param {string} path - Filesystem path to the SQLite database file
|
|
15
|
+
* @param {string} table - Target table name
|
|
16
|
+
* @param {Array<string>} columns - Column names for insertion
|
|
17
|
+
* @returns {object} Sink with write(records) and close() methods
|
|
18
|
+
*/
|
|
19
|
+
export default function sqliteSink(path, table, columns) {
|
|
20
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
21
|
+
throw new Error('Path must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
if (typeof table !== 'string' || table.length === 0) {
|
|
24
|
+
throw new Error('Table must be a non-empty string');
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
27
|
+
throw new Error('Columns must be a non-empty array');
|
|
28
|
+
}
|
|
29
|
+
const db = new Database(path);
|
|
30
|
+
db.pragma('journal_mode = WAL');
|
|
31
|
+
const placeholders = columns.map(() => {return '?'}).join(', ');
|
|
32
|
+
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
|
|
33
|
+
const statement = db.prepare(sql);
|
|
34
|
+
const transaction = db.transaction((records) => {
|
|
35
|
+
for (const record of records) {
|
|
36
|
+
statement.run(...columns.map((col) => {return record[col]}));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
/**
|
|
41
|
+
* Writes records to SQLite table inside a transaction.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array} records - Array of objects with keys matching columns
|
|
44
|
+
*/
|
|
45
|
+
write(records) {
|
|
46
|
+
transaction(records);
|
|
47
|
+
},
|
|
48
|
+
/**
|
|
49
|
+
* Closes the database connection.
|
|
50
|
+
*/
|
|
51
|
+
close() {
|
|
52
|
+
db.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import positive from './positive.js';
|
|
2
|
+
import budgetAccept from './budgetAccept.js';
|
|
3
|
+
|
|
4
|
+
export function stompSettings(options, prefetch) {
|
|
5
|
+
const config = { acceptTimeoutMs: 10000, stopTimeoutMs: 5000, maxMessageBytes: 1048576, ...options };
|
|
6
|
+
config.maxPendingBytes ??= prefetch * config.maxMessageBytes;
|
|
7
|
+
['acceptTimeoutMs', 'stopTimeoutMs', 'maxMessageBytes', 'maxPendingBytes'].forEach((key) => { positive(config[key], key); });
|
|
8
|
+
if (!Number.isSafeInteger(config.maxMessageBytes) || !Number.isSafeInteger(config.maxPendingBytes)) {
|
|
9
|
+
throw new TypeError('STOMP byte capacities must be safe integers');
|
|
10
|
+
}
|
|
11
|
+
return config;
|
|
12
|
+
}
|
|
13
|
+
function overflow() { return new Error('STOMP payload capacity exceeded'); }
|
|
14
|
+
function legacyRead(message, maximum) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
message.readString('utf-8', (error, body) => {
|
|
17
|
+
if (error) { reject(error); }
|
|
18
|
+
else if (Buffer.byteLength(body) > maximum) { reject(overflow()); }
|
|
19
|
+
else { resolve(body.toString()); }
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Native stompit frames are Readables: bound bytes before concatenating the body. */
|
|
24
|
+
export async function readStomp(message, maximum) {
|
|
25
|
+
if (!message[Symbol.asyncIterator]) { return legacyRead(message, maximum); }
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
for await (const chunk of message) {
|
|
29
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
30
|
+
bytes += buffer.length;
|
|
31
|
+
if (bytes > maximum) { throw overflow(); }
|
|
32
|
+
chunks.push(buffer);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat(chunks, bytes).toString('utf8');
|
|
35
|
+
}
|
|
36
|
+
/** Counters and global leases follow real acceptance, including after an uncertain timeout. */
|
|
37
|
+
export async function acceptStomp(ctx, body, settle) {
|
|
38
|
+
if (!ctx.lifecycle.active) { throw new Error('STOMP source stopped'); }
|
|
39
|
+
const bytes = Buffer.byteLength(body);
|
|
40
|
+
if (ctx.lifecycle.bytes + bytes > ctx.lifecycle.maxPendingBytes) { throw overflow(); }
|
|
41
|
+
ctx.lifecycle.bytes += bytes;
|
|
42
|
+
try {
|
|
43
|
+
return await budgetAccept(ctx.collector, { destination: ctx.destination, payload: body, settle }, bytes,
|
|
44
|
+
ctx.lifecycle, { signal: ctx.lifecycle.controller.signal });
|
|
45
|
+
} finally { ctx.lifecycle.bytes -= bytes; }
|
|
46
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import stompit from 'stompit';
|
|
2
|
+
import deadline from './deadline.js';
|
|
3
|
+
import positive from './positive.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds STOMP connect options from a URL.
|
|
7
|
+
*
|
|
8
|
+
* @param {URL} parsed - Parsed URL object
|
|
9
|
+
* @param {object} options - Connection options
|
|
10
|
+
* @returns {object} stompit connect parameters
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* STOMP SEND headers that persist the body on a durable RabbitMQ queue.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} destination - STOMP destination
|
|
16
|
+
* @returns {object} headers including persistent true
|
|
17
|
+
*/
|
|
18
|
+
export function stompHeaders(destination) {
|
|
19
|
+
return {
|
|
20
|
+
destination,
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
persistent: 'true'
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function connectParams(parsed, options) {
|
|
27
|
+
return {
|
|
28
|
+
host: parsed.hostname,
|
|
29
|
+
port: parseInt(parsed.port, 10) || 61613,
|
|
30
|
+
connectHeaders: {
|
|
31
|
+
host: options.host || parsed.hostname,
|
|
32
|
+
login: options.login || '',
|
|
33
|
+
passcode: options.passcode || ''
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One-shot STOMP publish of a JSON payload to a destination.
|
|
40
|
+
*
|
|
41
|
+
* Connects, sends one MESSAGE frame, disconnects. For operator decisions
|
|
42
|
+
* and other low-volume outbound events.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* await stompSend('stomp://rabbitmq:61613', '/exchange/scada.user_decisions',
|
|
46
|
+
* { machine: 'icht2', start: 1700000000, user: 'hmi', tags: ['heating'] },
|
|
47
|
+
* { login: 'guest', passcode: 'guest', host: '/' });
|
|
48
|
+
*
|
|
49
|
+
* @param {string} url - STOMP broker URL
|
|
50
|
+
* @param {string} destination - STOMP destination
|
|
51
|
+
* @param {object|string} payload - JSON-serializable body or raw string
|
|
52
|
+
* @param {object} [options] - STOMP connection options
|
|
53
|
+
* @param {string} [options.login] - Login
|
|
54
|
+
* @param {string} [options.passcode] - Passcode
|
|
55
|
+
* @param {string} [options.host] - Virtual host header
|
|
56
|
+
* @returns {Promise<void>} Resolves when the frame is sent and client disconnects
|
|
57
|
+
*/
|
|
58
|
+
export default function stompSend(url, destination, payload, options = {}) {
|
|
59
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
60
|
+
throw new Error('URL must be a non-empty string');
|
|
61
|
+
}
|
|
62
|
+
if (typeof destination !== 'string' || destination.length === 0) {
|
|
63
|
+
throw new Error('Destination must be a non-empty string');
|
|
64
|
+
}
|
|
65
|
+
const parsed = new URL(url);
|
|
66
|
+
const params = connectParams(parsed, options);
|
|
67
|
+
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
68
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
69
|
+
positive(timeoutMs, 'timeoutMs');
|
|
70
|
+
const state = { active: true, client: null, connecting: null };
|
|
71
|
+
const sent = new Promise((resolve, reject) => {
|
|
72
|
+
state.connecting = (options.connect || stompit.connect)({ ...params, timeout: timeoutMs }, (error, client) => {
|
|
73
|
+
if (error) { reject(error); return; }
|
|
74
|
+
state.client = client;
|
|
75
|
+
if (!state.active) { client.destroy(); return; }
|
|
76
|
+
client.on('error', reject);
|
|
77
|
+
try {
|
|
78
|
+
const frame = client.send(stompHeaders(destination), { onReceipt() {
|
|
79
|
+
if (state.active) { client.disconnect((failure) => { if (failure) { reject(failure); } else { resolve(); } }); }
|
|
80
|
+
} });
|
|
81
|
+
frame.end(body);
|
|
82
|
+
} catch (failure) { client.destroy(); reject(failure); }
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return deadline(sent, timeoutMs, 'STOMP receipt/disconnect', () => {
|
|
86
|
+
state.active = false;
|
|
87
|
+
(state.client || state.connecting)?.destroy();
|
|
88
|
+
}).finally(() => { state.active = false; });
|
|
89
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import stompit from 'stompit';
|
|
2
|
+
import follow from './follow.js';
|
|
3
|
+
import deadline from './deadline.js';
|
|
4
|
+
import { acceptStomp, readStomp, stompSettings } from './stompBody.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Idle state for STOMP source.
|
|
8
|
+
*
|
|
9
|
+
* @returns {object} State with subscribed() returning false
|
|
10
|
+
*/
|
|
11
|
+
function idle() {
|
|
12
|
+
return {
|
|
13
|
+
subscribed() {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Subscribed state for STOMP source.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} channel - STOMP channel
|
|
23
|
+
* @returns {object} State with subscribed() returning true
|
|
24
|
+
*/
|
|
25
|
+
function subscribed(channel, delivery) {
|
|
26
|
+
return {
|
|
27
|
+
subscribed() {
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
channel() {
|
|
31
|
+
return channel;
|
|
32
|
+
},
|
|
33
|
+
delivery
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds STOMP server configuration from a URL.
|
|
39
|
+
*
|
|
40
|
+
* @param {URL} parsed - Parsed URL object
|
|
41
|
+
* @param {object} options - Connection options
|
|
42
|
+
* @returns {object} Server config for ConnectFailover
|
|
43
|
+
*/
|
|
44
|
+
function serverConfig(parsed, options) {
|
|
45
|
+
return {
|
|
46
|
+
host: parsed.hostname,
|
|
47
|
+
port: parseInt(parsed.port, 10) || 61613,
|
|
48
|
+
connectHeaders: {
|
|
49
|
+
host: options.host || parsed.hostname,
|
|
50
|
+
login: options.login || '',
|
|
51
|
+
passcode: options.passcode || ''
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds settle helpers for one STOMP frame.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} channel - STOMP channel
|
|
60
|
+
* @param {object} message - STOMP frame
|
|
61
|
+
* @returns {{ ack: Function, nack: Function }}
|
|
62
|
+
*/
|
|
63
|
+
function settleOf(channel, message, lifecycle) {
|
|
64
|
+
let settled = false;
|
|
65
|
+
return {
|
|
66
|
+
ack() {
|
|
67
|
+
if (lifecycle.active && !settled) { channel.ack(message); settled = true; }
|
|
68
|
+
},
|
|
69
|
+
nack(requeue = true) {
|
|
70
|
+
if (lifecycle.active && !settled) { channel.nack(message, { requeue: String(requeue) }); settled = true; }
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delivers one STOMP body to the collector and settles the frame.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} ctx - delivery context with channel/message/destination/collector/manualAck
|
|
79
|
+
* @param {string} body - UTF-8 payload
|
|
80
|
+
* @returns {Promise<void>}
|
|
81
|
+
*/
|
|
82
|
+
async function deliver(ctx, body) {
|
|
83
|
+
const settle = settleOf(ctx.channel, ctx.message, ctx.lifecycle);
|
|
84
|
+
try {
|
|
85
|
+
await deadline(Promise.resolve().then(() => {
|
|
86
|
+
return acceptStomp(ctx, body, settle);
|
|
87
|
+
}), ctx.lifecycle.acceptTimeoutMs, 'STOMP acceptance', (error) => {
|
|
88
|
+
ctx.lifecycle.fatal = error; ctx.lifecycle.active = false; ctx.lifecycle.controller.abort(error); ctx.channel.close();
|
|
89
|
+
});
|
|
90
|
+
if (!ctx.manualAck) {
|
|
91
|
+
settle.ack();
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!ctx.manualAck) {
|
|
95
|
+
settle.nack();
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribes a channel and routes frames to the collector.
|
|
103
|
+
* Arrivals stay in subscribe order through follow, even when persist overlaps.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} channel - STOMP channel
|
|
106
|
+
* @param {string} destination - queue destination
|
|
107
|
+
* @param {object} collector - message collector
|
|
108
|
+
* @param {object} delivery - serial, manualAck and prefetch settings
|
|
109
|
+
* @returns {undefined}
|
|
110
|
+
*/
|
|
111
|
+
function bind(channel, destination, collector, delivery) {
|
|
112
|
+
const { serial, manualAck, prefetch, lifecycle } = delivery;
|
|
113
|
+
let pending = Promise.resolve();
|
|
114
|
+
channel.subscribe({ destination, ack: 'client-individual', 'prefetch-count': String(prefetch) }, (err, message) => {
|
|
115
|
+
if (err) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ctx = { channel, message, destination, collector, manualAck, lifecycle };
|
|
119
|
+
pending = follow(pending, () => {
|
|
120
|
+
if (!lifecycle.active) { return Promise.reject(new Error('STOMP source stopped')); }
|
|
121
|
+
return deadline(readStomp(message, lifecycle.maxMessageBytes), lifecycle.acceptTimeoutMs, 'STOMP read').catch((error) => {
|
|
122
|
+
lifecycle.fatal = error; lifecycle.active = false; channel.close(); throw error;
|
|
123
|
+
});
|
|
124
|
+
}, (body) => {
|
|
125
|
+
const task = deliver(ctx, body);
|
|
126
|
+
lifecycle.pending.add(task);
|
|
127
|
+
return task.finally(() => { lifecycle.pending.delete(task); });
|
|
128
|
+
}, serial);
|
|
129
|
+
lifecycle.chain = pending;
|
|
130
|
+
void pending.catch((error) => {
|
|
131
|
+
try { lifecycle.onError?.(error); } catch {
|
|
132
|
+
// Reporting cannot reject a transport callback.
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* STOMP subscription source for streaming messages to a collector.
|
|
140
|
+
*
|
|
141
|
+
* Messages are `{destination, payload, settle}`. Default ack-after-accept;
|
|
142
|
+
* with `manualAck` the collector owns settle. Arrivals enqueue in
|
|
143
|
+
* subscribe order. With `serial: false` persist may overlap after enqueue.
|
|
144
|
+
* A finite prefetch (default 32) bounds unacknowledged broker deliveries.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} url - STOMP broker URL
|
|
147
|
+
* @param {string} destination - STOMP destination
|
|
148
|
+
* @param {object} collector - Collector with accept()
|
|
149
|
+
* @param {object} [options] - Connection and delivery options
|
|
150
|
+
* @param {number} [options.prefetch=32] - Positive integer delivery window
|
|
151
|
+
* @returns {object} Source with start() and stop() methods
|
|
152
|
+
*/
|
|
153
|
+
export default function stompSource(url, destination, collector, options = {}) {
|
|
154
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
155
|
+
throw new Error('URL must be a non-empty string');
|
|
156
|
+
}
|
|
157
|
+
if (typeof destination !== 'string' || destination.length === 0) {
|
|
158
|
+
throw new Error('Destination must be a non-empty string');
|
|
159
|
+
}
|
|
160
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
161
|
+
throw new Error('Collector must have an accept() method');
|
|
162
|
+
}
|
|
163
|
+
const prefetch = options.prefetch ?? 32;
|
|
164
|
+
if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
|
|
165
|
+
throw new Error('prefetch must be a positive safe integer');
|
|
166
|
+
}
|
|
167
|
+
const settings = stompSettings(options, prefetch);
|
|
168
|
+
const parsed = new URL(url);
|
|
169
|
+
const serial = options.serial !== false;
|
|
170
|
+
const manualAck = options.manualAck === true;
|
|
171
|
+
let state = idle();
|
|
172
|
+
return {
|
|
173
|
+
start() {
|
|
174
|
+
if (state.subscribed()) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const failover = new stompit.ConnectFailover([serverConfig(parsed, options)], {
|
|
178
|
+
initialReconnectDelay: 10,
|
|
179
|
+
maxReconnectDelay: 30000,
|
|
180
|
+
maxReconnects: -1
|
|
181
|
+
});
|
|
182
|
+
const channel = new stompit.Channel(failover);
|
|
183
|
+
const lifecycle = { ...settings, active: true, controller: new AbortController(), pending: new Set(), bytes: 0 };
|
|
184
|
+
bind(channel, destination, collector, { serial, manualAck, prefetch, lifecycle });
|
|
185
|
+
state = subscribed(channel, lifecycle);
|
|
186
|
+
},
|
|
187
|
+
stop() {
|
|
188
|
+
if (!state.subscribed()) {
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
const stopped = state;
|
|
192
|
+
stopped.delivery.active = false;
|
|
193
|
+
stopped.delivery.controller.abort(new Error('STOMP source stopped'));
|
|
194
|
+
stopped.channel().close();
|
|
195
|
+
return deadline(Promise.allSettled([stopped.delivery.chain, ...stopped.delivery.pending]), options.stopTimeoutMs ?? 5000, 'STOMP stop').then(() => {
|
|
196
|
+
if (stopped.delivery.fatal) { throw stopped.delivery.fatal; }
|
|
197
|
+
if (state === stopped) { state = idle(); }
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { amqpSource } from '../streams/index.js';
|
|
2
2
|
import operationCodec from './operationCodec.js';
|
|
3
3
|
import operationSyncSink from './operationSyncSink.js';
|
|
4
4
|
|
|
@@ -43,25 +43,13 @@ export function operationConsumer(codec, channel) {
|
|
|
43
43
|
* const ingest = operationSyncIngest(amqpUrl, 'scada.operations.ingest', dataAccess.operations);
|
|
44
44
|
* await ingest.start();
|
|
45
45
|
*/
|
|
46
|
-
function subscribeOperations(queue, prefetch, codec) {
|
|
47
|
-
return async (channel) => {
|
|
48
|
-
await channel.assertQueue(queue, { durable: true });
|
|
49
|
-
channel.prefetch(prefetch);
|
|
50
|
-
const onMessage = operationConsumer(codec, channel);
|
|
51
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
52
|
-
return tag.consumerTag;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
46
|
export default function operationSyncIngest(amqpUrl, queue, operations, options = {}) {
|
|
57
47
|
const prefetch = options.prefetch || 32;
|
|
58
48
|
const sink = operationSyncSink(operations);
|
|
59
49
|
const codec = operationCodec(sink);
|
|
60
|
-
const session =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
options
|
|
64
|
-
);
|
|
50
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
51
|
+
accept(delivery) { return acceptOperationDeliver(codec, delivery.content); }
|
|
52
|
+
}, { ...options, prefetch });
|
|
65
53
|
return {
|
|
66
54
|
start() {
|
|
67
55
|
return session.start();
|