@yarkivaev/source-to-sink 1.3.2 → 1.3.4
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/modbusSource.js +76 -37
- package/src/postgresSink.js +30 -28
- package/src/stompSource.js +95 -44
- package/test/test_modbusSource.js +150 -0
- package/test/test_postgresSink.js +27 -0
- package/test/test_stompSource.js +9 -0
package/package.json
CHANGED
package/src/modbusSource.js
CHANGED
|
@@ -4,7 +4,7 @@ import pollingSource from './pollingSource.js';
|
|
|
4
4
|
/**
|
|
5
5
|
* Idle state for Modbus connection.
|
|
6
6
|
*
|
|
7
|
-
* @returns {object} State with
|
|
7
|
+
* @returns {object} State with no open attempt
|
|
8
8
|
*/
|
|
9
9
|
function idle() {
|
|
10
10
|
return {
|
|
@@ -13,6 +13,9 @@ function idle() {
|
|
|
13
13
|
},
|
|
14
14
|
retrying() {
|
|
15
15
|
return false;
|
|
16
|
+
},
|
|
17
|
+
opening() {
|
|
18
|
+
return false;
|
|
16
19
|
}
|
|
17
20
|
};
|
|
18
21
|
}
|
|
@@ -31,12 +34,34 @@ function retrying(handle) {
|
|
|
31
34
|
retrying() {
|
|
32
35
|
return true;
|
|
33
36
|
},
|
|
37
|
+
opening() {
|
|
38
|
+
return false;
|
|
39
|
+
},
|
|
34
40
|
cancel() {
|
|
35
41
|
clearTimeout(handle);
|
|
36
42
|
}
|
|
37
43
|
};
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* In-flight TCP or RTU connect state.
|
|
48
|
+
*
|
|
49
|
+
* @returns {object} State with opening() returning true
|
|
50
|
+
*/
|
|
51
|
+
function opening() {
|
|
52
|
+
return {
|
|
53
|
+
connected() {
|
|
54
|
+
return false;
|
|
55
|
+
},
|
|
56
|
+
retrying() {
|
|
57
|
+
return false;
|
|
58
|
+
},
|
|
59
|
+
opening() {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
/**
|
|
41
66
|
* Connected state for Modbus connection.
|
|
42
67
|
*
|
|
@@ -51,12 +76,44 @@ function connected(client) {
|
|
|
51
76
|
retrying() {
|
|
52
77
|
return false;
|
|
53
78
|
},
|
|
79
|
+
opening() {
|
|
80
|
+
return false;
|
|
81
|
+
},
|
|
54
82
|
disconnect() {
|
|
55
83
|
client.close();
|
|
56
84
|
}
|
|
57
85
|
};
|
|
58
86
|
}
|
|
59
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Starts one connect attempt and ignores results after stop().
|
|
90
|
+
*
|
|
91
|
+
* @param {object} bag - Mutable lifecycle bag
|
|
92
|
+
*/
|
|
93
|
+
function attempt(bag) {
|
|
94
|
+
const token = bag.epoch;
|
|
95
|
+
bag.state = opening();
|
|
96
|
+
bag.connect(bag.client).then(() => {
|
|
97
|
+
if (token !== bag.epoch) {
|
|
98
|
+
bag.client.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
bag.delay = bag.interval;
|
|
102
|
+
bag.state = connected(bag.client);
|
|
103
|
+
bag.source.start();
|
|
104
|
+
}).catch((err) => {
|
|
105
|
+
if (token !== bag.epoch) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
|
|
109
|
+
const handle = setTimeout(() => {
|
|
110
|
+
attempt(bag);
|
|
111
|
+
}, bag.delay * 1000);
|
|
112
|
+
bag.state = retrying(handle);
|
|
113
|
+
bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
60
117
|
/**
|
|
61
118
|
* Shared Modbus polling lifecycle for TCP and RTU transports.
|
|
62
119
|
*
|
|
@@ -64,60 +121,42 @@ function connected(client) {
|
|
|
64
121
|
* @returns {object} Source with start() and stop() methods
|
|
65
122
|
*/
|
|
66
123
|
function modbusPollingSource(options) {
|
|
67
|
-
const {
|
|
68
|
-
connect,
|
|
69
|
-
target,
|
|
70
|
-
address,
|
|
71
|
-
count,
|
|
72
|
-
interval,
|
|
73
|
-
collector,
|
|
74
|
-
clk,
|
|
75
|
-
client,
|
|
76
|
-
log
|
|
77
|
-
} = options;
|
|
78
|
-
let state = idle();
|
|
124
|
+
const { address, count, interval, collector, clk, client } = options;
|
|
79
125
|
async function fetch() {
|
|
80
126
|
const result = await client.readHoldingRegisters(address, count);
|
|
81
127
|
return [result.data];
|
|
82
128
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}).catch((err) => {
|
|
92
|
-
log.error(`Connection to ${target} failed, retrying in ${delay}s: ${err.message}`);
|
|
93
|
-
const handle = setTimeout(attempt, delay * 1000);
|
|
94
|
-
state = retrying(handle);
|
|
95
|
-
delay = Math.min(delay * 2, maxDelay);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
129
|
+
const bag = {
|
|
130
|
+
...options,
|
|
131
|
+
state: idle(),
|
|
132
|
+
epoch: 0,
|
|
133
|
+
maxDelay: 300,
|
|
134
|
+
source: pollingSource(fetch, interval, collector, clk),
|
|
135
|
+
delay: interval
|
|
136
|
+
};
|
|
98
137
|
return {
|
|
99
138
|
/**
|
|
100
139
|
* Connects to the Modbus device and starts polling.
|
|
101
140
|
*/
|
|
102
141
|
start() {
|
|
103
|
-
if (state.connected() || state.retrying()) {
|
|
142
|
+
if (bag.state.connected() || bag.state.retrying() || bag.state.opening()) {
|
|
104
143
|
return;
|
|
105
144
|
}
|
|
106
|
-
attempt();
|
|
145
|
+
attempt(bag);
|
|
107
146
|
},
|
|
108
147
|
/**
|
|
109
148
|
* Stops polling and disconnects from the Modbus device.
|
|
110
149
|
*/
|
|
111
150
|
stop() {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
state
|
|
151
|
+
bag.epoch += 1;
|
|
152
|
+
bag.source.stop();
|
|
153
|
+
if (bag.state.retrying()) {
|
|
154
|
+
bag.state.cancel();
|
|
116
155
|
}
|
|
117
|
-
if (state.connected()) {
|
|
118
|
-
|
|
119
|
-
state = idle();
|
|
156
|
+
if (bag.state.connected() || bag.state.opening()) {
|
|
157
|
+
bag.client.close();
|
|
120
158
|
}
|
|
159
|
+
bag.state = idle();
|
|
121
160
|
}
|
|
122
161
|
};
|
|
123
162
|
}
|
package/src/postgresSink.js
CHANGED
|
@@ -1,30 +1,5 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* PostgreSQL sink for batch record insertion.
|
|
5
|
-
*
|
|
6
|
-
* Creates a pg.Pool internally and implements the Sink
|
|
7
|
-
* interface for use with batch collectors. Supports optional
|
|
8
|
-
* conflict resolution via ON CONFLICT DO NOTHING or
|
|
9
|
-
* ON CONFLICT DO UPDATE SET.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value']);
|
|
13
|
-
* await sink.write([{ ts: Date.now(), value: 42 }]);
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* const sink = postgresSink('postgresql://localhost:5432/db', 'segments',
|
|
17
|
-
* ['machine', 'name', 'start_time'], { conflict: ['machine', 'start_time'] });
|
|
18
|
-
* await sink.write([{ machine: 'a', name: 'on', start_time: '2024-01-01' }]);
|
|
19
|
-
*
|
|
20
|
-
* @param {string} url - PostgreSQL URL (e.g., 'postgresql://localhost:5432/db')
|
|
21
|
-
* @param {string} table - Target table name
|
|
22
|
-
* @param {Array<string>} columns - Column names for insertion
|
|
23
|
-
* @param {object} [options] - Optional configuration
|
|
24
|
-
* @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
|
|
25
|
-
* @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
|
|
26
|
-
* @returns {object} Sink with write(records) method
|
|
27
|
-
*/
|
|
28
3
|
/**
|
|
29
4
|
* Removes duplicate records by conflict key within a batch.
|
|
30
5
|
*
|
|
@@ -70,6 +45,31 @@ function buildSuffix(options) {
|
|
|
70
45
|
return ` ON CONFLICT (${cols}) DO NOTHING`;
|
|
71
46
|
}
|
|
72
47
|
|
|
48
|
+
/**
|
|
49
|
+
* PostgreSQL sink for batch record insertion.
|
|
50
|
+
*
|
|
51
|
+
* Creates a pg.Pool internally unless `options.pool` is supplied.
|
|
52
|
+
* Supports optional conflict resolution via ON CONFLICT DO NOTHING or
|
|
53
|
+
* ON CONFLICT DO UPDATE SET. `write` accepts an optional queryable
|
|
54
|
+
* client so callers can run inserts inside an existing transaction.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value']);
|
|
58
|
+
* await sink.write([{ ts: Date.now(), value: 42 }]);
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* const sink = postgresSink(url, 'segments', cols, { pool, conflict: ['machine', 'start_time'] });
|
|
62
|
+
* await sink.write([{ machine: 'a', name: 'on', start_time: '2024-01-01' }], client);
|
|
63
|
+
*
|
|
64
|
+
* @param {string} url - PostgreSQL URL (e.g., 'postgresql://localhost:5432/db')
|
|
65
|
+
* @param {string} table - Target table name
|
|
66
|
+
* @param {Array<string>} columns - Column names for insertion
|
|
67
|
+
* @param {object} [options] - Optional configuration
|
|
68
|
+
* @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
|
|
69
|
+
* @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
|
|
70
|
+
* @param {object} [options.pool] - Existing pg Pool to reuse
|
|
71
|
+
* @returns {object} Sink with write(records, client?) method
|
|
72
|
+
*/
|
|
73
73
|
export default function postgresSink(url, table, columns, options = {}) {
|
|
74
74
|
if (typeof url !== 'string' || url.length === 0) {
|
|
75
75
|
throw new Error('URL must be a non-empty string');
|
|
@@ -80,7 +80,7 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
80
80
|
if (!Array.isArray(columns) || columns.length === 0) {
|
|
81
81
|
throw new Error('Columns must be a non-empty array');
|
|
82
82
|
}
|
|
83
|
-
const pool = new pg.Pool({ connectionString: url });
|
|
83
|
+
const pool = options.pool || new pg.Pool({ connectionString: url });
|
|
84
84
|
const suffix = buildSuffix(options);
|
|
85
85
|
const conflict = Array.isArray(options.conflict) ? options.conflict : [];
|
|
86
86
|
return {
|
|
@@ -88,9 +88,10 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
88
88
|
* Writes records to PostgreSQL table.
|
|
89
89
|
*
|
|
90
90
|
* @param {Array} records - Array of objects with keys matching columns
|
|
91
|
+
* @param {object} [client] - Optional pg client/pool with query()
|
|
91
92
|
* @returns {Promise} Promise resolving when insert completes
|
|
92
93
|
*/
|
|
93
|
-
write(records) {
|
|
94
|
+
write(records, client) {
|
|
94
95
|
const unique = deduplicate(records, conflict);
|
|
95
96
|
const placeholders = unique.map((record, i) => {
|
|
96
97
|
const offset = i * columns.length;
|
|
@@ -99,7 +100,8 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
99
100
|
}).join(', ');
|
|
100
101
|
const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}${suffix}`;
|
|
101
102
|
const values = unique.flatMap((record) => {return columns.map((col) => {return record[col]})});
|
|
102
|
-
|
|
103
|
+
const runner = client && typeof client.query === 'function' ? client : pool;
|
|
104
|
+
return runner.query(query, values);
|
|
103
105
|
}
|
|
104
106
|
};
|
|
105
107
|
}
|
package/src/stompSource.js
CHANGED
|
@@ -30,30 +30,6 @@ function subscribed(channel) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* STOMP subscription source for streaming messages to a collector.
|
|
35
|
-
*
|
|
36
|
-
* Subscribes to a STOMP destination and forwards raw messages to the collector.
|
|
37
|
-
* Messages are passed as {destination, payload} objects without parsing.
|
|
38
|
-
* Uses ConnectFailover for automatic reconnection with exponential backoff
|
|
39
|
-
* and Channel for transparent re-subscription. Acknowledges messages only
|
|
40
|
-
* after the collector has accepted them.
|
|
41
|
-
*
|
|
42
|
-
* @example
|
|
43
|
-
* const source = stompSource('stomp://localhost:61613', '/queue/sensors', collector);
|
|
44
|
-
* source.start();
|
|
45
|
-
* // ... later
|
|
46
|
-
* source.stop();
|
|
47
|
-
*
|
|
48
|
-
* @param {string} url - STOMP broker URL (e.g., 'stomp://localhost:61613')
|
|
49
|
-
* @param {string} destination - STOMP destination to subscribe (e.g., '/queue/sensors')
|
|
50
|
-
* @param {object} collector - Collector with accept() method receiving {destination, payload}
|
|
51
|
-
* @param {object} [options] - Optional STOMP connection options
|
|
52
|
-
* @param {string} [options.login] - Login for STOMP auth
|
|
53
|
-
* @param {string} [options.passcode] - Passcode for STOMP auth
|
|
54
|
-
* @param {string} [options.host] - Virtual host for STOMP connection
|
|
55
|
-
* @returns {object} Source with start() and stop() methods
|
|
56
|
-
*/
|
|
57
33
|
/**
|
|
58
34
|
* Builds STOMP server configuration from a URL.
|
|
59
35
|
*
|
|
@@ -73,6 +49,98 @@ function serverConfig(parsed, options) {
|
|
|
73
49
|
};
|
|
74
50
|
}
|
|
75
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Builds settle helpers for one STOMP frame.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} channel - STOMP channel
|
|
56
|
+
* @param {object} message - STOMP frame
|
|
57
|
+
* @returns {{ ack: Function, nack: Function }}
|
|
58
|
+
*/
|
|
59
|
+
function settleOf(channel, message) {
|
|
60
|
+
return {
|
|
61
|
+
ack() {
|
|
62
|
+
channel.ack(message);
|
|
63
|
+
},
|
|
64
|
+
nack() {
|
|
65
|
+
channel.nack(message);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Delivers one STOMP body to the collector and settles the frame.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} ctx - delivery context with channel/message/destination/collector/manualAck
|
|
74
|
+
* @param {string} body - UTF-8 payload
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
async function deliver(ctx, body) {
|
|
78
|
+
const settle = settleOf(ctx.channel, ctx.message);
|
|
79
|
+
try {
|
|
80
|
+
await ctx.collector.accept({ destination: ctx.destination, payload: body, settle });
|
|
81
|
+
if (!ctx.manualAck) {
|
|
82
|
+
settle.ack();
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!ctx.manualAck) {
|
|
86
|
+
settle.nack();
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ignores a rejected delivery promise when running non-serial.
|
|
94
|
+
*
|
|
95
|
+
* @param {Error} error - rejected reason
|
|
96
|
+
* @returns {undefined}
|
|
97
|
+
*/
|
|
98
|
+
function ignoreDelivery(error) {
|
|
99
|
+
return error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Subscribes a channel and routes frames to the collector.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} channel - STOMP channel
|
|
106
|
+
* @param {string} destination - queue destination
|
|
107
|
+
* @param {object} collector - message collector
|
|
108
|
+
* @param {boolean} serial - global serial delivery
|
|
109
|
+
* @param {boolean} manualAck - collector settles frames
|
|
110
|
+
* @returns {undefined}
|
|
111
|
+
*/
|
|
112
|
+
function bind(channel, destination, collector, serial, manualAck) {
|
|
113
|
+
let pending = Promise.resolve();
|
|
114
|
+
channel.subscribe({ destination, ack: 'client-individual' }, (err, message) => {
|
|
115
|
+
if (err) { return; }
|
|
116
|
+
message.readString('utf-8', (readErr, body) => {
|
|
117
|
+
if (readErr) { return; }
|
|
118
|
+
const ctx = { channel, message, destination, collector, manualAck };
|
|
119
|
+
function work() {
|
|
120
|
+
return deliver(ctx, body.toString());
|
|
121
|
+
}
|
|
122
|
+
if (serial) {
|
|
123
|
+
pending = pending.then(work, work);
|
|
124
|
+
} else {
|
|
125
|
+
void work().catch(ignoreDelivery);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* STOMP subscription source for streaming messages to a collector.
|
|
133
|
+
*
|
|
134
|
+
* Messages are `{destination, payload, settle}`. Default ack-after-accept;
|
|
135
|
+
* with `manualAck` the collector owns settle. With `serial: false`
|
|
136
|
+
* deliveries are not globally sequenced.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} url - STOMP broker URL
|
|
139
|
+
* @param {string} destination - STOMP destination
|
|
140
|
+
* @param {object} collector - Collector with accept()
|
|
141
|
+
* @param {object} [options] - Connection and delivery options
|
|
142
|
+
* @returns {object} Source with start() and stop() methods
|
|
143
|
+
*/
|
|
76
144
|
export default function stompSource(url, destination, collector, options = {}) {
|
|
77
145
|
if (typeof url !== 'string' || url.length === 0) {
|
|
78
146
|
throw new Error('URL must be a non-empty string');
|
|
@@ -84,11 +152,10 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
84
152
|
throw new Error('Collector must have an accept() method');
|
|
85
153
|
}
|
|
86
154
|
const parsed = new URL(url);
|
|
155
|
+
const serial = options.serial !== false;
|
|
156
|
+
const manualAck = options.manualAck === true;
|
|
87
157
|
let state = idle();
|
|
88
158
|
return {
|
|
89
|
-
/**
|
|
90
|
-
* Starts subscribing to the STOMP destination.
|
|
91
|
-
*/
|
|
92
159
|
start() {
|
|
93
160
|
if (state.subscribed()) {
|
|
94
161
|
return;
|
|
@@ -99,25 +166,9 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
99
166
|
maxReconnects: -1
|
|
100
167
|
});
|
|
101
168
|
const channel = new stompit.Channel(failover);
|
|
102
|
-
|
|
103
|
-
const subscribe = { destination, ack: 'client-individual' };
|
|
104
|
-
channel.subscribe(subscribe, (err, message) => {
|
|
105
|
-
if (err) { return; }
|
|
106
|
-
message.readString('utf-8', (readErr, body) => {
|
|
107
|
-
if (readErr) { return; }
|
|
108
|
-
pending = pending.then(async () => {
|
|
109
|
-
try {
|
|
110
|
-
await collector.accept({ destination, payload: body.toString() });
|
|
111
|
-
channel.ack(message);
|
|
112
|
-
} catch { channel.nack(message); }
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
});
|
|
169
|
+
bind(channel, destination, collector, serial, manualAck);
|
|
116
170
|
state = subscribed(channel);
|
|
117
171
|
},
|
|
118
|
-
/**
|
|
119
|
-
* Stops subscribing to the STOMP destination.
|
|
120
|
-
*/
|
|
121
172
|
stop() {
|
|
122
173
|
if (!state.subscribed()) {
|
|
123
174
|
return;
|
|
@@ -1,8 +1,89 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
+
import net from 'node:net';
|
|
2
3
|
import { describe, it } from 'mocha';
|
|
3
4
|
import modbusSource from '../src/modbusSource.js';
|
|
4
5
|
import fakeClock from './fakeClock.js';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* TCP listener that counts concurrent sockets on an ephemeral port.
|
|
9
|
+
*
|
|
10
|
+
* @returns {Promise<object>} Gate with port, live, peak, close
|
|
11
|
+
*/
|
|
12
|
+
function countingListen() {
|
|
13
|
+
let live = 0;
|
|
14
|
+
let peak = 0;
|
|
15
|
+
const sockets = new Set();
|
|
16
|
+
const server = net.createServer((sock) => {
|
|
17
|
+
live += 1;
|
|
18
|
+
if (live > peak) {
|
|
19
|
+
peak = live;
|
|
20
|
+
}
|
|
21
|
+
sockets.add(sock);
|
|
22
|
+
sock.on('close', () => {
|
|
23
|
+
sockets.delete(sock);
|
|
24
|
+
live -= 1;
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
server.listen(0, '127.0.0.1', () => {
|
|
29
|
+
resolve({
|
|
30
|
+
port: server.address().port,
|
|
31
|
+
live() {
|
|
32
|
+
return live;
|
|
33
|
+
},
|
|
34
|
+
peak() {
|
|
35
|
+
return peak;
|
|
36
|
+
},
|
|
37
|
+
close() {
|
|
38
|
+
for (const sock of sockets) {
|
|
39
|
+
sock.destroy();
|
|
40
|
+
}
|
|
41
|
+
return new Promise((done) => {
|
|
42
|
+
server.close(done);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves after the given delay.
|
|
52
|
+
*
|
|
53
|
+
* @param {number} ms - Delay in milliseconds
|
|
54
|
+
* @returns {Promise<void>} Timer promise
|
|
55
|
+
*/
|
|
56
|
+
function settle(ms) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
setTimeout(resolve, ms);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Waits until probe is true or the timeout elapses.
|
|
64
|
+
*
|
|
65
|
+
* @param {Function} probe - Condition
|
|
66
|
+
* @param {number} limit - Timeout in milliseconds
|
|
67
|
+
* @returns {Promise<void>} Resolves when probe holds
|
|
68
|
+
*/
|
|
69
|
+
function until(probe, limit) {
|
|
70
|
+
const deadline = Date.now() + limit;
|
|
71
|
+
function tick(resolve, reject) {
|
|
72
|
+
if (probe()) {
|
|
73
|
+
resolve();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() >= deadline) {
|
|
77
|
+
reject(new Error('condition did not hold before timeout'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
tick(resolve, reject);
|
|
82
|
+
}, 20);
|
|
83
|
+
}
|
|
84
|
+
return new Promise(tick);
|
|
85
|
+
}
|
|
86
|
+
|
|
6
87
|
describe('modbusSource', () => {
|
|
7
88
|
it('throws on empty host', () => {
|
|
8
89
|
const collector = { accept: () => {} };
|
|
@@ -118,4 +199,73 @@ describe('modbusSource', () => {
|
|
|
118
199
|
source.stop();
|
|
119
200
|
assert.strictEqual(true, true, 'Should not crash on unreachable host');
|
|
120
201
|
});
|
|
202
|
+
|
|
203
|
+
it('does not open a second TCP socket when start is called twice', async function() {
|
|
204
|
+
const gate = await countingListen();
|
|
205
|
+
const source = modbusSource(
|
|
206
|
+
'127.0.0.1',
|
|
207
|
+
gate.port,
|
|
208
|
+
4000,
|
|
209
|
+
14,
|
|
210
|
+
5,
|
|
211
|
+
{ accept() {} },
|
|
212
|
+
fakeClock(1 + Math.floor(Math.random() * 100)),
|
|
213
|
+
{ error() {} }
|
|
214
|
+
);
|
|
215
|
+
source.start();
|
|
216
|
+
await until(() => {
|
|
217
|
+
return gate.live() >= 1;
|
|
218
|
+
}, 2000);
|
|
219
|
+
source.start();
|
|
220
|
+
await settle(150);
|
|
221
|
+
const peak = gate.peak();
|
|
222
|
+
source.stop();
|
|
223
|
+
await gate.close();
|
|
224
|
+
assert.strictEqual(peak, 1, 'second start opened another Modbus TCP socket');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('closes the TCP socket when stop is called after connect', async function() {
|
|
228
|
+
const gate = await countingListen();
|
|
229
|
+
const source = modbusSource(
|
|
230
|
+
'127.0.0.1',
|
|
231
|
+
gate.port,
|
|
232
|
+
4000,
|
|
233
|
+
14,
|
|
234
|
+
5,
|
|
235
|
+
{ accept() {} },
|
|
236
|
+
fakeClock(3 + Math.floor(Math.random() * 100)),
|
|
237
|
+
{ error() {} }
|
|
238
|
+
);
|
|
239
|
+
source.start();
|
|
240
|
+
await until(() => {
|
|
241
|
+
return gate.live() >= 1;
|
|
242
|
+
}, 2000);
|
|
243
|
+
source.stop();
|
|
244
|
+
await until(() => {
|
|
245
|
+
return gate.live() === 0;
|
|
246
|
+
}, 2000);
|
|
247
|
+
const live = gate.live();
|
|
248
|
+
await gate.close();
|
|
249
|
+
assert.strictEqual(live, 0, 'stop after connect left a Modbus TCP socket open');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not keep a TCP socket when stop is called during connect', async function() {
|
|
253
|
+
const gate = await countingListen();
|
|
254
|
+
const source = modbusSource(
|
|
255
|
+
'127.0.0.1',
|
|
256
|
+
gate.port,
|
|
257
|
+
4000,
|
|
258
|
+
14,
|
|
259
|
+
5,
|
|
260
|
+
{ accept() {} },
|
|
261
|
+
fakeClock(7 + Math.floor(Math.random() * 100)),
|
|
262
|
+
{ error() {} }
|
|
263
|
+
);
|
|
264
|
+
source.start();
|
|
265
|
+
source.stop();
|
|
266
|
+
await settle(400);
|
|
267
|
+
const live = gate.live();
|
|
268
|
+
await gate.close();
|
|
269
|
+
assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
|
|
270
|
+
});
|
|
121
271
|
});
|
|
@@ -108,4 +108,31 @@ describe('postgresSink', () => {
|
|
|
108
108
|
const result = deduplicate(records, []);
|
|
109
109
|
assert.strictEqual(result.length, 2, 'Should not deduplicate without conflict columns');
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it('reuses an injected pool when writing', async () => {
|
|
113
|
+
const queries = [];
|
|
114
|
+
const pool = {
|
|
115
|
+
query(sql, values) {
|
|
116
|
+
queries.push({ sql, values });
|
|
117
|
+
return Promise.resolve({ rowCount: 1 });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value'], { pool });
|
|
121
|
+
await sink.write([{ ts: 1700000000, value: 42 }]);
|
|
122
|
+
assert.strictEqual(queries.length, 1, 'injected pool was not used for write');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('routes write through an explicit client when provided', async () => {
|
|
126
|
+
const pool = { query() { return Promise.reject(new Error('pool must not be used')); } };
|
|
127
|
+
const clientQueries = [];
|
|
128
|
+
const client = {
|
|
129
|
+
query(sql, values) {
|
|
130
|
+
clientQueries.push({ sql, values });
|
|
131
|
+
return Promise.resolve({ rowCount: 1 });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value'], { pool });
|
|
135
|
+
await sink.write([{ ts: 1700000001, value: 7 }], client);
|
|
136
|
+
assert.strictEqual(clientQueries.length, 1, 'explicit client was not used for write');
|
|
137
|
+
});
|
|
111
138
|
});
|
package/test/test_stompSource.js
CHANGED
|
@@ -51,4 +51,13 @@ describe('stompSource', () => {
|
|
|
51
51
|
assert.strictEqual(typeof source.start, 'function', 'Should have start method');
|
|
52
52
|
assert.strictEqual(typeof source.stop, 'function', 'Should have stop method');
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
it('accepts manualAck and serial options without throwing', () => {
|
|
56
|
+
const collector = { accept: () => {} };
|
|
57
|
+
const source = stompSource('stomp://localhost:61613', '/queue/test', collector, {
|
|
58
|
+
manualAck: true,
|
|
59
|
+
serial: false
|
|
60
|
+
});
|
|
61
|
+
assert.strictEqual(typeof source.start, 'function', 'manualAck source missing start');
|
|
62
|
+
});
|
|
54
63
|
});
|