@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.75",
3
+ "version": "2.3.77",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- async start() {
66
- if (session) {
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
- const active = session;
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 amqp from 'amqplib';
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 session;
49
+ let opened = false;
50
+ const session = durableConsume(
51
+ amqpUrl,
52
+ subscribeRelay(queue, prefetch, sink),
53
+ options
54
+ );
40
55
  return {
41
- async start() {
42
- if (session) {
43
- return;
56
+ start() {
57
+ if (!opened) {
58
+ opened = true;
59
+ sink.start();
44
60
  }
45
- const conn = await amqp.connect(amqpUrl);
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
- const active = session;
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 amqp from 'amqplib';
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
- let session;
60
+ const session = durableConsume(
61
+ amqpUrl,
62
+ subscribeOperations(queue, prefetch, codec),
63
+ options
64
+ );
51
65
  return {
52
- async start() {
53
- if (session) {
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
- async stop() {
72
- const active = session;
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
  }