@yarkivaev/scada 2.3.75 → 2.3.77
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/package.json +1 -1
- package/src/application/supervisorSink.js +3 -2
- package/src/infrastructure/ingest/durableConsume.js +169 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +19 -26
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +23 -26
- package/src/infrastructure/persistence/postgresPool.js +20 -1
- package/src/infrastructure/sync/operationSyncIngest.js +20 -29
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import migrate, { migratePrivileged } from '../infrastructure/ingest/db/migrate.
|
|
|
7
7
|
import stateDataFromPool from '../infrastructure/persistence/stateDataFromPool.js';
|
|
8
8
|
import { parseRequestTimeoutMs } from '../infrastructure/ingest/parseRequestTimeoutMs.js';
|
|
9
9
|
import pg from 'pg';
|
|
10
|
+
import { holdClient } from '../infrastructure/persistence/postgresPool.js';
|
|
10
11
|
import path from 'path';
|
|
11
12
|
import { fileURLToPath } from 'url';
|
|
12
13
|
|
|
@@ -59,7 +60,7 @@ export function readDeploymentConfig(env) {
|
|
|
59
60
|
*/
|
|
60
61
|
export default function supervisorSink(env = process.env) {
|
|
61
62
|
const cfg = readDeploymentConfig(env);
|
|
62
|
-
const pool = new pg.Pool({ connectionString: cfg.postgres });
|
|
63
|
+
const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
|
|
63
64
|
const segments = segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline);
|
|
64
65
|
const decisions = decisionPipeline(cfg.stomp, pool, cfg.pipeline);
|
|
65
66
|
const alerts = alertPipeline(cfg.stomp, pool, cfg.pipeline);
|
|
@@ -79,7 +80,7 @@ export default function supervisorSink(env = process.env) {
|
|
|
79
80
|
if (!cfg.postgresAdmin) {
|
|
80
81
|
throw new Error('POSTGRES_ADMIN_URL is required for supervisor-sink database migrations');
|
|
81
82
|
}
|
|
82
|
-
const adminPool = new pg.Pool({ connectionString: cfg.postgresAdmin });
|
|
83
|
+
const adminPool = holdClient(new pg.Pool({ connectionString: cfg.postgresAdmin }));
|
|
83
84
|
try {
|
|
84
85
|
await migrate(adminPool, cfg.migrationsDir, cfg.sinkDbProfile);
|
|
85
86
|
await migratePrivileged(adminPool, cfg.migrationsDir, cfg.sinkDbProfile);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import amqp from 'amqplib';
|
|
2
|
+
|
|
3
|
+
function connectBroker(url) {
|
|
4
|
+
return amqp.connect(url, { heartbeat: 10 });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function scheduleLater(fn, pause) {
|
|
8
|
+
return setTimeout(fn, pause);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clearLater(handle) {
|
|
12
|
+
clearTimeout(handle);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function reportFailure(error) {
|
|
16
|
+
process.stderr.write(`amqp consume failed ${error.message}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nextDelay(delay) {
|
|
20
|
+
return Math.min(delay * 2, 30000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function blankState() {
|
|
24
|
+
return {
|
|
25
|
+
generation: 0,
|
|
26
|
+
timer: undefined,
|
|
27
|
+
stopping: false,
|
|
28
|
+
delay: 1000,
|
|
29
|
+
session: undefined
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function running(state) {
|
|
34
|
+
return Boolean(state.session) || (state.generation > 0 && !state.stopping);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scheduleRetry(ctx, generation) {
|
|
38
|
+
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const pause = ctx.state.delay;
|
|
42
|
+
ctx.state.delay = nextDelay(ctx.state.delay);
|
|
43
|
+
ctx.state.timer = ctx.schedule(() => {
|
|
44
|
+
ctx.state.timer = undefined;
|
|
45
|
+
return ctx.open(generation);
|
|
46
|
+
}, pause);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bindClose(ctx, generation, live) {
|
|
50
|
+
let dropped = false;
|
|
51
|
+
function lose() {
|
|
52
|
+
if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
dropped = true;
|
|
56
|
+
if (ctx.state.session === live) {
|
|
57
|
+
ctx.state.session = undefined;
|
|
58
|
+
}
|
|
59
|
+
scheduleRetry(ctx, generation);
|
|
60
|
+
}
|
|
61
|
+
live.connection.on('error', lose);
|
|
62
|
+
live.connection.on('close', lose);
|
|
63
|
+
live.channel.on('error', lose);
|
|
64
|
+
live.channel.on('close', lose);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function accept(ctx, generation, connection) {
|
|
68
|
+
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
69
|
+
await connection.close();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return connection.createChannel();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function keep(ctx, generation, connection, channel, tag) {
|
|
76
|
+
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
77
|
+
await channel.close();
|
|
78
|
+
await connection.close();
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const live = { connection, channel, tag };
|
|
82
|
+
ctx.state.session = live;
|
|
83
|
+
ctx.state.delay = 1000;
|
|
84
|
+
bindClose(ctx, generation, live);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function open(ctx, generation) {
|
|
89
|
+
if (ctx.state.stopping || generation !== ctx.state.generation) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const connection = await ctx.connect(ctx.url);
|
|
94
|
+
const channel = await accept(ctx, generation, connection);
|
|
95
|
+
if (!channel) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const tag = await ctx.subscribe(channel);
|
|
99
|
+
await keep(ctx, generation, connection, channel, tag);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
ctx.report(error);
|
|
102
|
+
scheduleRetry(ctx, generation);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function begin(ctx) {
|
|
107
|
+
if (running(ctx.state)) {
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
}
|
|
110
|
+
ctx.state.stopping = false;
|
|
111
|
+
ctx.state.generation += 1;
|
|
112
|
+
ctx.state.delay = 1000;
|
|
113
|
+
return open(ctx, ctx.state.generation);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function end(ctx) {
|
|
117
|
+
ctx.state.stopping = true;
|
|
118
|
+
ctx.state.generation += 1;
|
|
119
|
+
if (ctx.state.timer) {
|
|
120
|
+
ctx.clear(ctx.state.timer);
|
|
121
|
+
ctx.state.timer = undefined;
|
|
122
|
+
}
|
|
123
|
+
const live = ctx.state.session;
|
|
124
|
+
ctx.state.session = undefined;
|
|
125
|
+
if (!live) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await live.channel.cancel(live.tag);
|
|
129
|
+
await live.channel.close();
|
|
130
|
+
await live.connection.close();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Keeps one AMQP consumer subscribed across broker restarts.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} url - RabbitMQ AMQP URL
|
|
137
|
+
* @param {function} subscribe - async (channel) => consumerTag
|
|
138
|
+
* @param {object} [options] - connect, schedule, clear, report overrides
|
|
139
|
+
* @returns {object} session with start and stop
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* const session = durableConsume('amqp://localhost', async (channel) => {
|
|
143
|
+
* const tag = await channel.consume('scada.telemetry.ingest', () => {});
|
|
144
|
+
* return tag.consumerTag;
|
|
145
|
+
* });
|
|
146
|
+
* await session.start();
|
|
147
|
+
*/
|
|
148
|
+
export default function durableConsume(url, subscribe, options = {}) {
|
|
149
|
+
const ctx = {
|
|
150
|
+
url,
|
|
151
|
+
subscribe,
|
|
152
|
+
connect: options.connect || connectBroker,
|
|
153
|
+
schedule: options.schedule || scheduleLater,
|
|
154
|
+
clear: options.clear || clearLater,
|
|
155
|
+
report: options.report || reportFailure,
|
|
156
|
+
state: blankState(),
|
|
157
|
+
open(generation) {
|
|
158
|
+
return open(ctx, generation);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
start() {
|
|
163
|
+
return begin(ctx);
|
|
164
|
+
},
|
|
165
|
+
stop() {
|
|
166
|
+
return end(ctx);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import amqp from 'amqplib';
|
|
2
1
|
import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import durableConsume from '../durableConsume.js';
|
|
3
3
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
4
4
|
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
5
5
|
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
@@ -50,44 +50,37 @@ export function telemetryConsumer(codec, channel, onSeen) {
|
|
|
50
50
|
* @param {object} [options] - batch and prefetch options
|
|
51
51
|
* @returns {object} ingest with start and stop
|
|
52
52
|
*/
|
|
53
|
+
function subscribeMetrics(queue, prefetch, codec, onSeen) {
|
|
54
|
+
return async (channel) => {
|
|
55
|
+
await channel.assertQueue(queue, { durable: true });
|
|
56
|
+
channel.prefetch(prefetch);
|
|
57
|
+
const onMessage = telemetryConsumer(codec, channel, onSeen);
|
|
58
|
+
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
59
|
+
return tag.consumerTag;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
54
64
|
const prefetch = options.prefetch || 32;
|
|
55
65
|
const size = options.size || 100;
|
|
56
66
|
const interval = options.interval || 5;
|
|
57
67
|
const threshold = options.threshold || 5;
|
|
58
68
|
const timeout = options.timeout || 60;
|
|
59
|
-
let session;
|
|
60
69
|
const clk = clock();
|
|
61
70
|
const breaker = circuit(threshold, timeout, clk);
|
|
62
71
|
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
63
72
|
const codec = metricsCodec(collector);
|
|
73
|
+
const session = durableConsume(
|
|
74
|
+
amqpUrl,
|
|
75
|
+
subscribeMetrics(queue, prefetch, codec, options.onSeen),
|
|
76
|
+
options
|
|
77
|
+
);
|
|
64
78
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const conn = await amqp.connect(amqpUrl);
|
|
70
|
-
const ch = await conn.createChannel();
|
|
71
|
-
if (session) {
|
|
72
|
-
await ch.close();
|
|
73
|
-
await conn.close();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
await ch.assertQueue(queue, { durable: true });
|
|
77
|
-
ch.prefetch(prefetch);
|
|
78
|
-
const onMessage = telemetryConsumer(codec, ch, options.onSeen);
|
|
79
|
-
const tag = await ch.consume(queue, onMessage, { noAck: false });
|
|
80
|
-
session = { conn, channel: ch, tag: tag.consumerTag };
|
|
79
|
+
start() {
|
|
80
|
+
return session.start();
|
|
81
81
|
},
|
|
82
82
|
async stop() {
|
|
83
|
-
|
|
84
|
-
if (!active) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
session = undefined;
|
|
88
|
-
await active.channel.cancel(active.tag);
|
|
89
|
-
await active.channel.close();
|
|
90
|
-
await active.conn.close();
|
|
83
|
+
await session.stop();
|
|
91
84
|
collector.stop();
|
|
92
85
|
}
|
|
93
86
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import durableConsume from '../durableConsume.js';
|
|
2
2
|
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,37 +34,34 @@ export function relayConsumer(sink, channel) {
|
|
|
34
34
|
* @param {object} [options] - prefetch count
|
|
35
35
|
* @returns {object} Relay with start and stop
|
|
36
36
|
*/
|
|
37
|
+
function subscribeRelay(queue, prefetch, sink) {
|
|
38
|
+
return async (channel) => {
|
|
39
|
+
await channel.assertQueue(queue, { durable: true });
|
|
40
|
+
channel.prefetch(prefetch);
|
|
41
|
+
const onMessage = relayConsumer(sink, channel);
|
|
42
|
+
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
43
|
+
return tag.consumerTag;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
38
48
|
const prefetch = options.prefetch || 32;
|
|
39
|
-
let
|
|
49
|
+
let opened = false;
|
|
50
|
+
const session = durableConsume(
|
|
51
|
+
amqpUrl,
|
|
52
|
+
subscribeRelay(queue, prefetch, sink),
|
|
53
|
+
options
|
|
54
|
+
);
|
|
40
55
|
return {
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
56
|
+
start() {
|
|
57
|
+
if (!opened) {
|
|
58
|
+
opened = true;
|
|
59
|
+
sink.start();
|
|
44
60
|
}
|
|
45
|
-
|
|
46
|
-
const ch = await conn.createChannel();
|
|
47
|
-
if (session) {
|
|
48
|
-
await ch.close();
|
|
49
|
-
await conn.close();
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
await ch.assertQueue(queue, { durable: true });
|
|
53
|
-
ch.prefetch(prefetch);
|
|
54
|
-
sink.start();
|
|
55
|
-
const onMessage = relayConsumer(sink, ch);
|
|
56
|
-
const tag = await ch.consume(queue, onMessage, { noAck: false });
|
|
57
|
-
session = { conn, channel: ch, tag: tag.consumerTag };
|
|
61
|
+
return session.start();
|
|
58
62
|
},
|
|
59
63
|
async stop() {
|
|
60
|
-
|
|
61
|
-
if (!active) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
session = undefined;
|
|
65
|
-
await active.channel.cancel(active.tag);
|
|
66
|
-
await active.channel.close();
|
|
67
|
-
await active.conn.close();
|
|
64
|
+
await session.stop();
|
|
68
65
|
sink.stop();
|
|
69
66
|
}
|
|
70
67
|
};
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Logs an idle postgres client failure and leaves the pool open.
|
|
5
|
+
* node-postgres emits `error` when an idle socket dies. With no listener
|
|
6
|
+
* the process exits.
|
|
7
|
+
*
|
|
8
|
+
* @param {import('pg').Pool} pool - long-lived pool
|
|
9
|
+
* @returns {import('pg').Pool} the same pool
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* holdClient(new pg.Pool({ connectionString: process.env.POSTGRES_URL }));
|
|
13
|
+
*/
|
|
14
|
+
export function holdClient(pool) {
|
|
15
|
+
const host = pool.options && pool.options.host ? pool.options.host : 'postgres';
|
|
16
|
+
pool.on('error', (err) => {
|
|
17
|
+
process.stderr.write(`[scada] postgres idle client failed host=${host} message=${err.message}\n`);
|
|
18
|
+
});
|
|
19
|
+
return pool;
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
/**
|
|
4
23
|
* Creates a PostgreSQL pool for supervisor state reads.
|
|
5
24
|
*
|
|
@@ -20,5 +39,5 @@ export default function postgresPool(options = {}) {
|
|
|
20
39
|
if (!url) {
|
|
21
40
|
return undefined;
|
|
22
41
|
}
|
|
23
|
-
return new pg.Pool({ connectionString: url });
|
|
42
|
+
return holdClient(new pg.Pool({ connectionString: url }));
|
|
24
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import durableConsume from '../ingest/durableConsume.js';
|
|
2
2
|
import operationCodec from './operationCodec.js';
|
|
3
3
|
import operationSyncSink from './operationSyncSink.js';
|
|
4
4
|
|
|
@@ -43,40 +43,31 @@ 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
|
+
|
|
46
56
|
export default function operationSyncIngest(amqpUrl, queue, operations, options = {}) {
|
|
47
57
|
const prefetch = options.prefetch || 32;
|
|
48
58
|
const sink = operationSyncSink(operations);
|
|
49
59
|
const codec = operationCodec(sink);
|
|
50
|
-
|
|
60
|
+
const session = durableConsume(
|
|
61
|
+
amqpUrl,
|
|
62
|
+
subscribeOperations(queue, prefetch, codec),
|
|
63
|
+
options
|
|
64
|
+
);
|
|
51
65
|
return {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const conn = await amqp.connect(amqpUrl);
|
|
57
|
-
const ch = await conn.createChannel();
|
|
58
|
-
await ch.assertQueue(queue, { durable: true });
|
|
59
|
-
ch.prefetch(prefetch);
|
|
60
|
-
const onMessage = operationConsumer(codec, ch);
|
|
61
|
-
const tag = await ch.consume(queue, onMessage, { noAck: false });
|
|
62
|
-
const started = { conn, channel: ch, tag: tag.consumerTag };
|
|
63
|
-
if (session) {
|
|
64
|
-
await ch.cancel(tag.consumerTag);
|
|
65
|
-
await ch.close();
|
|
66
|
-
await conn.close();
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
session = started;
|
|
66
|
+
start() {
|
|
67
|
+
return session.start();
|
|
70
68
|
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!active) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
session = undefined;
|
|
77
|
-
await active.channel.cancel(active.tag);
|
|
78
|
-
await active.channel.close();
|
|
79
|
-
await active.conn.close();
|
|
69
|
+
stop() {
|
|
70
|
+
return session.stop();
|
|
80
71
|
}
|
|
81
72
|
};
|
|
82
73
|
}
|