@yarkivaev/source-to-sink 1.3.5 → 1.3.7

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/source-to-sink",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
package/src/follow.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Continues a delivery chain after a rejected prior step.
3
+ *
4
+ * @param {Promise} pending - prior chain
5
+ * @returns {Promise} settled chain
6
+ */
7
+ function resume(pending) {
8
+ return pending.then((value) => {
9
+ return value;
10
+ }, (error) => {
11
+ return error;
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Chains one arrival: read the body, then start deliver.
17
+ * Serial waits for deliver. Otherwise persist may overlap after enqueue.
18
+ *
19
+ * @param {Promise} pending - prior arrival
20
+ * @param {Function} read - returns a promise for the body
21
+ * @param {Function} deliver - starts persist for one body
22
+ * @param {boolean} serial - wait for persist before the next arrival
23
+ * @returns {Promise} chain
24
+ *
25
+ * @example
26
+ * let pending = Promise.resolve();
27
+ * pending = follow(pending, () => Promise.resolve('a'), write, false);
28
+ */
29
+ export default function follow(pending, read, deliver, serial) {
30
+ return resume(pending).then(async () => {
31
+ const body = await read();
32
+ const run = deliver(body);
33
+ if (serial) {
34
+ return run;
35
+ }
36
+ void run.catch((error) => {
37
+ return error;
38
+ });
39
+ return undefined;
40
+ });
41
+ }
@@ -85,6 +85,28 @@ function connected(client) {
85
85
  };
86
86
  }
87
87
 
88
+ /**
89
+ * Destroys a Modbus client without throwing if already closed.
90
+ *
91
+ * @param {object} client - ModbusRTU client
92
+ */
93
+ function drop(client) {
94
+ if (client && typeof client.destroy === 'function') {
95
+ client.destroy(() => {
96
+ return undefined;
97
+ });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Builds a ModbusRTU client for one connect attempt.
103
+ *
104
+ * @returns {object} Fresh ModbusRTU client
105
+ */
106
+ function create() {
107
+ return new ModbusRTU();
108
+ }
109
+
88
110
  /**
89
111
  * Starts one connect attempt and ignores results after stop().
90
112
  *
@@ -93,9 +115,11 @@ function connected(client) {
93
115
  function attempt(bag) {
94
116
  const token = bag.epoch;
95
117
  bag.state = opening();
118
+ drop(bag.client);
119
+ bag.client = bag.create();
96
120
  bag.connect(bag.client).then(() => {
97
121
  if (token !== bag.epoch) {
98
- bag.client.close();
122
+ drop(bag.client);
99
123
  return;
100
124
  }
101
125
  bag.delay = bag.interval;
@@ -105,6 +129,7 @@ function attempt(bag) {
105
129
  if (token !== bag.epoch) {
106
130
  return;
107
131
  }
132
+ drop(bag.client);
108
133
  bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
109
134
  const handle = setTimeout(() => {
110
135
  attempt(bag);
@@ -121,19 +146,20 @@ function attempt(bag) {
121
146
  * @returns {object} Source with start() and stop() methods
122
147
  */
123
148
  function modbusPollingSource(options) {
124
- const { address, count, interval, collector, clk, client } = options;
125
- async function fetch() {
126
- const result = await client.readHoldingRegisters(address, count);
127
- return [result.data];
128
- }
149
+ const { address, count, interval, collector, clk } = options;
129
150
  const bag = {
130
151
  ...options,
152
+ client: undefined,
131
153
  state: idle(),
132
154
  epoch: 0,
133
155
  maxDelay: 300,
134
- source: pollingSource(fetch, interval, collector, clk),
135
156
  delay: interval
136
157
  };
158
+ async function fetch() {
159
+ const result = await bag.client.readHoldingRegisters(address, count);
160
+ return [result.data];
161
+ }
162
+ bag.source = pollingSource(fetch, interval, collector, clk);
137
163
  return {
138
164
  /**
139
165
  * Connects to the Modbus device and starts polling.
@@ -153,9 +179,8 @@ function modbusPollingSource(options) {
153
179
  if (bag.state.retrying()) {
154
180
  bag.state.cancel();
155
181
  }
156
- if (bag.state.connected() || bag.state.opening()) {
157
- bag.client.close();
158
- }
182
+ drop(bag.client);
183
+ bag.client = undefined;
159
184
  bag.state = idle();
160
185
  }
161
186
  };
@@ -195,11 +220,11 @@ export default function modbusSource(host, port, address, count, interval, colle
195
220
  if (typeof count !== 'number' || count <= 0) {
196
221
  throw new Error(`Count must be a positive number, got: ${count}`);
197
222
  }
198
- const client = new ModbusRTU();
199
223
  function connect(modbusClient) {
200
224
  return modbusClient.connectTCP(host, { port });
201
225
  }
202
226
  return modbusPollingSource({
227
+ create,
203
228
  connect,
204
229
  target: `${host}:${port}`,
205
230
  address,
@@ -207,7 +232,6 @@ export default function modbusSource(host, port, address, count, interval, colle
207
232
  interval,
208
233
  collector,
209
234
  clk,
210
- client,
211
235
  log
212
236
  });
213
237
  }
@@ -248,7 +272,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
248
272
  if (typeof count !== 'number' || count <= 0) {
249
273
  throw new Error(`Count must be a positive number, got: ${count}`);
250
274
  }
251
- const client = new ModbusRTU();
252
275
  const slaveId = serial.slaveId || 1;
253
276
  const dataBits = serial.dataBits || 8;
254
277
  const stopBits = serial.stopBits || 1;
@@ -264,6 +287,7 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
264
287
  });
265
288
  }
266
289
  return modbusPollingSource({
290
+ create,
267
291
  connect,
268
292
  target: path,
269
293
  address,
@@ -271,7 +295,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
271
295
  interval,
272
296
  collector,
273
297
  clk,
274
- client,
275
298
  log
276
299
  });
277
300
  }
@@ -64,14 +64,17 @@ export default function pollingSource(fetch, interval, collector, clk) {
64
64
  }
65
65
  let state = idle();
66
66
  let since = clk.millis();
67
- async function poll() {
67
+ function poll() {
68
68
  const from = since;
69
69
  const until = clk.millis();
70
70
  since = until;
71
- const result = await fetch(from, until);
72
- for (const entry of result) {
73
- collector.accept(entry);
74
- }
71
+ Promise.resolve(fetch(from, until)).then((result) => {
72
+ for (const entry of result) {
73
+ collector.accept(entry);
74
+ }
75
+ }).catch(() => {
76
+ return undefined;
77
+ });
75
78
  }
76
79
  return {
77
80
  /**
@@ -32,7 +32,7 @@ export function deduplicate(records, keys) {
32
32
  /**
33
33
  * Builds the ON CONFLICT SQL suffix from options.
34
34
  *
35
- * @param {object} options - Sink options with conflict and update arrays
35
+ * @param {object} options - Sink options with conflict, update, and optional updateWhere
36
36
  * @returns {string} SQL suffix or empty string
37
37
  */
38
38
  function buildSuffix(options) {
@@ -40,7 +40,10 @@ function buildSuffix(options) {
40
40
  const cols = options.conflict.join(', ');
41
41
  if (Array.isArray(options.update) && options.update.length > 0) {
42
42
  const sets = options.update.map((col) => {return `${col} = EXCLUDED.${col}`}).join(', ');
43
- return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}`;
43
+ const guard = typeof options.updateWhere === 'string' && options.updateWhere.length > 0
44
+ ? ` WHERE ${options.updateWhere}`
45
+ : '';
46
+ return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}${guard}`;
44
47
  }
45
48
  return ` ON CONFLICT (${cols}) DO NOTHING`;
46
49
  }
@@ -67,6 +70,7 @@ function buildSuffix(options) {
67
70
  * @param {object} [options] - Optional configuration
68
71
  * @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
69
72
  * @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
73
+ * @param {string} [options.updateWhere] - Optional WHERE guard on DO UPDATE
70
74
  * @param {object} [options.pool] - Existing pg Pool to reuse
71
75
  * @returns {object} Sink with write(records, client?) method
72
76
  */
@@ -1,4 +1,5 @@
1
1
  import stompit from 'stompit';
2
+ import follow from './follow.js';
2
3
 
3
4
  /**
4
5
  * Idle state for STOMP source.
@@ -90,41 +91,46 @@ async function deliver(ctx, body) {
90
91
  }
91
92
 
92
93
  /**
93
- * Ignores a rejected delivery promise when running non-serial.
94
+ * Reads one STOMP frame body as text.
94
95
  *
95
- * @param {Error} error - rejected reason
96
- * @returns {undefined}
96
+ * @param {object} message - STOMP frame
97
+ * @returns {Promise<string>} UTF-8 body
97
98
  */
98
- function ignoreDelivery(error) {
99
- return error;
99
+ function read(message) {
100
+ return new Promise((resolve, reject) => {
101
+ message.readString('utf-8', (error, body) => {
102
+ if (error) {
103
+ reject(error);
104
+ return;
105
+ }
106
+ resolve(body.toString());
107
+ });
108
+ });
100
109
  }
101
110
 
102
111
  /**
103
112
  * Subscribes a channel and routes frames to the collector.
113
+ * Arrivals stay in subscribe order through follow, even when persist overlaps.
104
114
  *
105
115
  * @param {object} channel - STOMP channel
106
116
  * @param {string} destination - queue destination
107
117
  * @param {object} collector - message collector
108
- * @param {boolean} serial - global serial delivery
118
+ * @param {boolean} serial - wait for persist before the next arrival
109
119
  * @param {boolean} manualAck - collector settles frames
110
120
  * @returns {undefined}
111
121
  */
112
122
  function bind(channel, destination, collector, serial, manualAck) {
113
123
  let pending = Promise.resolve();
114
124
  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
- });
125
+ if (err) {
126
+ return;
127
+ }
128
+ const ctx = { channel, message, destination, collector, manualAck };
129
+ pending = follow(pending, () => {
130
+ return read(message);
131
+ }, (body) => {
132
+ return deliver(ctx, body);
133
+ }, serial);
128
134
  });
129
135
  }
130
136
 
@@ -132,8 +138,8 @@ function bind(channel, destination, collector, serial, manualAck) {
132
138
  * STOMP subscription source for streaming messages to a collector.
133
139
  *
134
140
  * 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.
141
+ * with `manualAck` the collector owns settle. Arrivals enqueue in
142
+ * subscribe order. With `serial: false` persist may overlap after enqueue.
137
143
  *
138
144
  * @param {string} url - STOMP broker URL
139
145
  * @param {string} destination - STOMP destination
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import follow from '../src/follow.js';
4
+
5
+ describe('follow', () => {
6
+ it('accepts in subscribe order when the first body is slower', async () => {
7
+ const order = [];
8
+ const first = `ä-${Math.random().toString(16).slice(2)}`;
9
+ const second = `ö-${Math.random().toString(16).slice(2)}`;
10
+ const pause = (ms) => {
11
+ return new Promise((resolve) => {
12
+ setTimeout(resolve, ms);
13
+ });
14
+ };
15
+ const deliver = (body) => {
16
+ order.push(body);
17
+ return Promise.resolve();
18
+ };
19
+ let pending = Promise.resolve();
20
+ pending = follow(pending, () => {
21
+ return pause(40).then(() => {
22
+ return first;
23
+ });
24
+ }, deliver, false);
25
+ pending = follow(pending, () => {
26
+ return Promise.resolve(second);
27
+ }, deliver, false);
28
+ await pending;
29
+ assert.deepStrictEqual(
30
+ order,
31
+ [first, second],
32
+ 'a slower first body reordered accept ahead of subscribe'
33
+ );
34
+ });
35
+ });
@@ -59,6 +59,27 @@ function settle(ms) {
59
59
  });
60
60
  }
61
61
 
62
+ /**
63
+ * Collects unhandledRejection events until stop().
64
+ *
65
+ * @returns {object} Collector with seen() and stop()
66
+ */
67
+ function watchRejects() {
68
+ const seen = [];
69
+ function onReject(err) {
70
+ seen.push(err);
71
+ }
72
+ process.on('unhandledRejection', onReject);
73
+ return {
74
+ seen() {
75
+ return seen.length;
76
+ },
77
+ stop() {
78
+ process.off('unhandledRejection', onReject);
79
+ }
80
+ };
81
+ }
82
+
62
83
  /**
63
84
  * Waits until probe is true or the timeout elapses.
64
85
  *
@@ -200,6 +221,28 @@ describe('modbusSource', () => {
200
221
  assert.strictEqual(true, true, 'Should not crash on unreachable host');
201
222
  });
202
223
 
224
+ it('does not emit unhandled rejection after connection retry', async function() {
225
+ this.timeout(4000);
226
+ const watch = watchRejects();
227
+ const log = { error() {} };
228
+ const port = 65000 + Math.floor(Math.random() * 500);
229
+ const source = modbusSource(
230
+ '127.0.0.1',
231
+ port,
232
+ 4000,
233
+ 14,
234
+ 0.05,
235
+ { accept() {} },
236
+ fakeClock(1 + Math.floor(Math.random() * 100)),
237
+ log
238
+ );
239
+ source.start();
240
+ await settle(400);
241
+ source.stop();
242
+ watch.stop();
243
+ assert.strictEqual(watch.seen(), 0, 'retry leaked an unhandled rejection');
244
+ });
245
+
203
246
  it('does not open a second TCP socket when start is called twice', async function() {
204
247
  const gate = await countingListen();
205
248
  const source = modbusSource(
@@ -91,6 +91,26 @@ describe('pollingSource', () => {
91
91
  assert.strictEqual(count, before, 'Should stop polling after stop');
92
92
  });
93
93
 
94
+ it('does not emit unhandled rejection when fetch rejects', async () => {
95
+ const seen = [];
96
+ function onReject(err) {
97
+ seen.push(err);
98
+ }
99
+ process.on('unhandledRejection', onReject);
100
+ const label = `\u00e9${Math.random()}`;
101
+ const fetch = () => {
102
+ return Promise.reject(new Error(label));
103
+ };
104
+ const source = pollingSource(fetch, 0.03, { accept() {} }, fakeClock(1 + Math.floor(Math.random() * 100)));
105
+ source.start();
106
+ await new Promise((resolve) => {
107
+ setTimeout(resolve, 80);
108
+ });
109
+ source.stop();
110
+ process.off('unhandledRejection', onReject);
111
+ assert.strictEqual(seen.length, 0, 'poll rejection leaked as unhandled');
112
+ });
113
+
94
114
  it('throws on missing fetch', () => {
95
115
  const collector = { accept: () => {} };
96
116
  const clk = fakeClock(0);
@@ -122,6 +122,28 @@ describe('postgresSink', () => {
122
122
  assert.strictEqual(queries.length, 1, 'injected pool was not used for write');
123
123
  });
124
124
 
125
+ it('appends updateWhere to the conflict update', async () => {
126
+ const queries = [];
127
+ const pool = {
128
+ query(sql) {
129
+ queries.push(sql);
130
+ return Promise.resolve({ rowCount: 1 });
131
+ }
132
+ };
133
+ const sink = postgresSink('postgresql://localhost:5432/db', 'segments',
134
+ ['machine', 'duration'], {
135
+ pool,
136
+ conflict: ['machine'],
137
+ update: ['duration'],
138
+ updateWhere: 'segments.duration = 0 OR EXCLUDED.duration > 0'
139
+ });
140
+ await sink.write([{ machine: `ä-${Math.random()}`, duration: 0 }]);
141
+ assert.ok(
142
+ queries[0].includes('WHERE segments.duration = 0 OR EXCLUDED.duration > 0'),
143
+ 'conflict update did not keep the monotonic duration guard'
144
+ );
145
+ });
146
+
125
147
  it('routes write through an explicit client when provided', async () => {
126
148
  const pool = { query() { return Promise.reject(new Error('pool must not be used')); } };
127
149
  const clientQueries = [];