@yarkivaev/source-to-sink 1.3.9 → 1.3.11
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 +157 -6
- package/index.js +6 -0
- package/package.json +7 -5
- package/src/amqpSink/publication.js +31 -0
- package/src/amqpSink/session.js +82 -0
- package/src/amqpSink.js +47 -0
- package/src/amqpSource/session.js +111 -0
- package/src/amqpSource.js +70 -0
- package/src/intervalSource.js +47 -0
- package/src/positive.js +5 -0
- package/src/serialSource.js +95 -0
- package/src/stompSource.js +2 -2
- package/test/integration_amqp.js +79 -0
- package/test/testIntervalSource.js +62 -0
- package/test/testSerialSource.js +67 -0
- package/test/test_amqpSink.js +125 -0
- package/test/test_amqpSource.js +153 -0
- package/test/test_stompSource.js +30 -0
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@ A library for building data streaming pipelines with batching and circuit breake
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install source-to-sink
|
|
8
|
+
npm install @yarkivaev/source-to-sink
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
13
|
```javascript
|
|
14
|
-
import { batch, circuit, timedBatch, clock, clickhouseSink, mqttSource } from 'source-to-sink';
|
|
14
|
+
import { batch, circuit, timedBatch, clock, clickhouseSink, mqttSource } from '@yarkivaev/source-to-sink';
|
|
15
15
|
|
|
16
16
|
const clk = clock();
|
|
17
17
|
const breaker = circuit(5, 60, clk);
|
|
@@ -35,10 +35,6 @@ source.start();
|
|
|
35
35
|
| `mqttSource(url, topic, collector)` | MQTT subscription source |
|
|
36
36
|
| `lokiSource(url, query, interval, collector, clock)` | Loki polling source |
|
|
37
37
|
|
|
38
|
-
## License
|
|
39
|
-
|
|
40
|
-
MIT
|
|
41
|
-
|
|
42
38
|
### STOMP delivery window
|
|
43
39
|
|
|
44
40
|
`stompSource(url, destination, collector, { prefetch: 32 })` bounds broker deliveries
|
|
@@ -47,3 +43,158 @@ integer to tune throughput. A zero (unlimited) window is rejected because a
|
|
|
47
43
|
backlog can otherwise exhaust the consumer's heap. With `manualAck: true`, the
|
|
48
44
|
collector remains responsible for settling frames. If it flushes only by count,
|
|
49
45
|
its batch must not exceed the prefetch window; a timed flush also avoids stalls.
|
|
46
|
+
|
|
47
|
+
Manual collectors may call `settle.nack(false)` to dead-letter invalid messages. Configure a durable dead-letter queue and broker policy before enabling this; without a route RabbitMQ discards rejected messages. `settle.nack()` retains retry behaviour.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## Transport sources and lifecycle
|
|
51
|
+
|
|
52
|
+
The sources below receive a collector with `accept(record)`. Return a promise
|
|
53
|
+
that resolves when processing is accepted; rejecting it reports a processing
|
|
54
|
+
failure. Keep application decoding, routing, SQL queries and cursor updates in
|
|
55
|
+
the collector. Await `stop()` before stopping downstream collectors or sinks,
|
|
56
|
+
so in-flight acceptance can finish. A collector that never settles also prevents
|
|
57
|
+
a draining stop from finishing.
|
|
58
|
+
|
|
59
|
+
### Serial chunks
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
import { serialSource } from '@yarkivaev/source-to-sink';
|
|
63
|
+
|
|
64
|
+
const source = serialSource([
|
|
65
|
+
{ path: '/dev/ttyUSB0', baudRate: 9600, dataBits: 8, parity: 'none', stopBits: 1 }
|
|
66
|
+
], {
|
|
67
|
+
accept({ device, chunk }) {
|
|
68
|
+
return decodeAndStore(device.path, chunk);
|
|
69
|
+
}
|
|
70
|
+
}, { retryMs: 1000, onError: (error, device) => console.error(device.path, error) });
|
|
71
|
+
|
|
72
|
+
await source.start();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each port has its own open/retry loop. `chunk` is the original buffer and `device`
|
|
76
|
+
is the original configuration object; framing and decoding belong in the
|
|
77
|
+
collector. Duplicate paths are rejected. Errors or a closed port trigger cleanup
|
|
78
|
+
and retry without interrupting other devices. `start()` initiates opens;
|
|
79
|
+
`stop()` waits for pending opens, closes ports, removes listeners and drains
|
|
80
|
+
in-flight acceptance before restart.
|
|
81
|
+
|
|
82
|
+
For testing or another port implementation, pass `openPort(device)`, returning a
|
|
83
|
+
port or promise for a port. The port supports EventEmitter `data`, `error` and
|
|
84
|
+
`close` events plus `close(callback)`. The default opens `serialport` lazily.
|
|
85
|
+
Serial input is a push stream; the source does not impose a collector backlog
|
|
86
|
+
limit or pause the device.
|
|
87
|
+
|
|
88
|
+
### Periodic collection
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
92
|
+
|
|
93
|
+
const source = intervalSource(1000, {
|
|
94
|
+
async accept({ ts }) {
|
|
95
|
+
const records = await readChanges(ts);
|
|
96
|
+
await publish(records);
|
|
97
|
+
// Advance a cursor only after publication succeeds.
|
|
98
|
+
}
|
|
99
|
+
}, { immediate: true, onError: console.error });
|
|
100
|
+
|
|
101
|
+
await source.start();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`intervalMs` must be positive. Ticks carry a Unix timestamp in milliseconds.
|
|
105
|
+
The default `immediate: false` waits one interval before the first tick. With
|
|
106
|
+
`immediate: true`, `start()` waits for the first acceptance to settle. Subsequent
|
|
107
|
+
ticks wait the interval after the previous acceptance settles, so slow work never
|
|
108
|
+
overlaps another tick. Rejections call `onError` and scheduling continues.
|
|
109
|
+
`stop()` cancels scheduling and drains the active tick; restart waits for that
|
|
110
|
+
drain. Tests can inject `schedule(callback, milliseconds)` and `clear(handle)`.
|
|
111
|
+
|
|
112
|
+
### AMQP consumption
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
import { amqpSource } from '@yarkivaev/source-to-sink';
|
|
116
|
+
|
|
117
|
+
const source = amqpSource('amqp://localhost', 'readings', {
|
|
118
|
+
async accept(delivery) {
|
|
119
|
+
await storeReading(JSON.parse(delivery.content.toString()));
|
|
120
|
+
}
|
|
121
|
+
}, {
|
|
122
|
+
queueOptions: { durable: true },
|
|
123
|
+
exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
|
|
124
|
+
bindings: [{ exchange: 'telemetry', pattern: 'reading.#' }],
|
|
125
|
+
prefetch: 32,
|
|
126
|
+
onError: console.error
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await source.start();
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Deliveries contain `content`, `fields`, `properties`, `ack()` and
|
|
133
|
+
`reject(requeue = true)`. By default the source acknowledges only after
|
|
134
|
+
`collector.accept` resolves; rejection requeues the delivery and reports the
|
|
135
|
+
error. Settlement is idempotent within a delivery. Set `ackMode: 'manual'` to
|
|
136
|
+
let the collector choose `ack()` or `reject(false)` for dead-lettering; configure
|
|
137
|
+
a broker dead-letter route if those messages must be retained. A collector that
|
|
138
|
+
settles manually should still await its processing work so stop can drain it.
|
|
139
|
+
|
|
140
|
+
The default queue is durable and the default prefetch is 32. Choose a finite,
|
|
141
|
+
positive prefetch to bound outstanding broker deliveries. With count-based
|
|
142
|
+
batching, the batch must fit within that window, or use a timed flush.
|
|
143
|
+
Connection/channel failure or consumer cancellation causes reconnection with
|
|
144
|
+
backoff from one to 30 seconds. `stop()` cancels the consumer, drains acceptance,
|
|
145
|
+
and closes the channel and connection. `active()` means the lifecycle is running,
|
|
146
|
+
including while reconnecting; it does not prove a broker connection is ready.
|
|
147
|
+
|
|
148
|
+
`amqpSession(url, subscribeChannel, options)` exposes that same lifecycle for
|
|
149
|
+
custom channel topology or consumption. The async `subscribeChannel(channel)`
|
|
150
|
+
returns a consumer tag for cancellation. `options.drain(channel)` can await
|
|
151
|
+
application work before channel closure. Both APIs accept `onError` (or `report`),
|
|
152
|
+
`connect(url)`, `schedule(callback, milliseconds)` and `clear(handle)`; the latter
|
|
153
|
+
three are useful for testing. The default connection uses a ten-second heartbeat.
|
|
154
|
+
|
|
155
|
+
### AMQP publication
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
import { amqpSink } from '@yarkivaev/source-to-sink';
|
|
159
|
+
|
|
160
|
+
const sink = amqpSink('amqp://localhost', {
|
|
161
|
+
exchanges: [{ name: 'telemetry', type: 'topic', options: { durable: true } }],
|
|
162
|
+
maxPending: 1024,
|
|
163
|
+
onError: console.error
|
|
164
|
+
});
|
|
165
|
+
await sink.start();
|
|
166
|
+
await sink.write([{
|
|
167
|
+
exchange: 'telemetry', routingKey: 'reading.temperature',
|
|
168
|
+
body: JSON.stringify({ value: 42 }), options: { persistent: true }
|
|
169
|
+
}]);
|
|
170
|
+
await sink.stop();
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`write(records)` resolves after broker publisher confirms for every record. Each
|
|
174
|
+
record requires an exchange, routing key and string or Buffer body; `options`
|
|
175
|
+
passes publication options to amqplib. A confirm does not mean a consumer has
|
|
176
|
+
processed the message or that an unroutable message reached a queue.
|
|
177
|
+
|
|
178
|
+
`maxPending` (default 1024) bounds outstanding confirmations. Writes reject while
|
|
179
|
+
the connection is unavailable, when the channel awaits its `drain` event, or when
|
|
180
|
+
the batch would exceed that bound. There is no unbounded offline queue. A write
|
|
181
|
+
can partially succeed before rejection; callers must account for that when
|
|
182
|
+
choosing retries or advancing cursors.
|
|
183
|
+
|
|
184
|
+
Connection loss or `confirmTimeoutMs` expiry rejects uncertain publications and
|
|
185
|
+
reconnects for future writes. **The sink never automatically replays records**:
|
|
186
|
+
the broker may already have accepted them. Use application idempotency when
|
|
187
|
+
retrying uncertain results. `active()` reports whether a live channel exists.
|
|
188
|
+
`start()` waits for connection and topology setup; a failed initial attempt
|
|
189
|
+
rejects while background retries continue until stopped.
|
|
190
|
+
|
|
191
|
+
Other options are `retryMs` (1000), `connectTimeoutMs` (5000),
|
|
192
|
+
`confirmTimeoutMs` (10000), `onError(error)`, and injectable
|
|
193
|
+
`connect(url, connectionOptions)`. Exchange declarations default to type `topic`
|
|
194
|
+
and durable options when omitted. `stop()` closes the session and rejects pending
|
|
195
|
+
confirms; it does not flush them. For graceful shutdown, first stop and drain
|
|
196
|
+
sources, await outstanding `write()` promises, then stop the sink.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
package/index.js
CHANGED
|
@@ -43,3 +43,9 @@ export { default as modbusSource, modbusRtuSource } from './src/modbusSource.js'
|
|
|
43
43
|
export { default as modbusRtuBusSource } from './src/modbusRtuBusSource.js';
|
|
44
44
|
export { default as mqttSink } from './src/mqttSink.js';
|
|
45
45
|
export { default as sqliteSink } from './src/sqliteSink.js';
|
|
46
|
+
|
|
47
|
+
export { default as amqpSink } from './src/amqpSink.js';
|
|
48
|
+
export { default as amqpSource } from './src/amqpSource.js';
|
|
49
|
+
export { default as amqpSession } from './src/amqpSource/session.js';
|
|
50
|
+
export { default as serialSource } from './src/serialSource.js';
|
|
51
|
+
export { default as intervalSource } from './src/intervalSource.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/source-to-sink",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
4
4
|
"description": "Generic library for building data streaming pipelines",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"mqtt": "^5.0.0",
|
|
13
12
|
"@clickhouse/client": "^1.0.0",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
13
|
+
"amqplib": "^0.10.9",
|
|
14
|
+
"better-sqlite3": "^11.0.0",
|
|
16
15
|
"modbus-serial": "^8.0.0",
|
|
17
|
-
"
|
|
16
|
+
"mqtt": "^5.0.0",
|
|
17
|
+
"pg": "^8.0.0",
|
|
18
|
+
"serialport": "^12.0.0",
|
|
19
|
+
"stompit": "^1.0.0"
|
|
18
20
|
},
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"@eslint/js": "^9.39.2",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Confirms one send, without replaying an outcome the broker may already have accepted. */
|
|
2
|
+
export default function publication(live, record, timeoutMs) {
|
|
3
|
+
if (!record || typeof record.exchange !== 'string' || typeof record.routingKey !== 'string'
|
|
4
|
+
|| !(typeof record.body === 'string' || Buffer.isBuffer(record.body))) {
|
|
5
|
+
return Promise.reject(new TypeError('AMQP record requires exchange, routingKey and string/Buffer body'));
|
|
6
|
+
}
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const body = Buffer.isBuffer(record.body) ? record.body : Buffer.from(record.body);
|
|
9
|
+
let settled = false;
|
|
10
|
+
const timer = { handle: null };
|
|
11
|
+
function settle(error) {
|
|
12
|
+
if (!settled) {
|
|
13
|
+
settled = true;
|
|
14
|
+
clearTimeout(timer.handle);
|
|
15
|
+
live.pending.delete(settle);
|
|
16
|
+
if (error) { reject(error); } else { resolve(); }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
timer.handle = setTimeout(() => {
|
|
20
|
+
const error = new Error('AMQP publish confirmation timed out');
|
|
21
|
+
settle(error);
|
|
22
|
+
live.fail(error);
|
|
23
|
+
}, timeoutMs);
|
|
24
|
+
live.pending.add(settle);
|
|
25
|
+
try {
|
|
26
|
+
if (!live.channel.publish(record.exchange, record.routingKey, body, record.options || {}, settle)) {
|
|
27
|
+
live.blocked = true;
|
|
28
|
+
}
|
|
29
|
+
} catch (error) { settle(error); }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import amqp from 'amqplib';
|
|
2
|
+
|
|
3
|
+
export function report(state, error) {
|
|
4
|
+
try { state.options.onError?.(error); } catch {
|
|
5
|
+
// Diagnostics cannot break recovery.
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function closeLive(live) {
|
|
9
|
+
if (!live) { return Promise.resolve(); }
|
|
10
|
+
live.closing ??= Promise.resolve().then(async () => {
|
|
11
|
+
live.pending.forEach((settle) => { settle(new Error('AMQP connection closed before confirmation')); });
|
|
12
|
+
if (live.channel) { await live.channel.close().catch(() => { return undefined; }); }
|
|
13
|
+
if (live.connection) { await live.connection.close().catch(() => { return undefined; }); }
|
|
14
|
+
});
|
|
15
|
+
return live.closing;
|
|
16
|
+
}
|
|
17
|
+
function retry(state, epoch) {
|
|
18
|
+
if (state.wanted && state.epoch === epoch && !state.retry) {
|
|
19
|
+
state.retry = setTimeout(() => {
|
|
20
|
+
state.retry = null;
|
|
21
|
+
void state.reopen().catch((error) => { report(state, error); });
|
|
22
|
+
}, state.options.retryMs);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function drop(state, live, error) {
|
|
26
|
+
if (live.dead) { return; }
|
|
27
|
+
live.dead = true;
|
|
28
|
+
if (state.live === live) { state.live = null; }
|
|
29
|
+
if (error) { report(state, error); }
|
|
30
|
+
void closeLive(live).then(() => { retry(state, live.epoch); });
|
|
31
|
+
}
|
|
32
|
+
function bind(state, live, handle) {
|
|
33
|
+
handle.on('error', (error) => { drop(state, live, error); });
|
|
34
|
+
handle.on('close', () => { drop(state, live); });
|
|
35
|
+
}
|
|
36
|
+
async function configure(state, live) {
|
|
37
|
+
// This attempt owns live; stop closes it after opening settles and epoch is checked below.
|
|
38
|
+
// eslint-disable-next-line require-atomic-updates
|
|
39
|
+
live.channel = await live.connection.createConfirmChannel();
|
|
40
|
+
bind(state, live, live.channel);
|
|
41
|
+
live.channel.on('drain', () => { live.blocked = false; });
|
|
42
|
+
await Promise.all((state.options.exchanges || []).map((item) => {
|
|
43
|
+
return live.channel.assertExchange(item.name, item.type || 'topic', item.options || {durable: true});
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
async function current(state, live) {
|
|
47
|
+
if (live.epoch !== state.epoch || !state.wanted) {
|
|
48
|
+
await closeLive(live);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function keep(state, live) {
|
|
54
|
+
if (live.epoch !== state.epoch || !state.wanted) { return closeLive(live); }
|
|
55
|
+
state.live = live;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
async function connect(state, epoch) {
|
|
59
|
+
const live = { connection: null, channel: null, pending: new Set(), epoch, dead: false, blocked: false,
|
|
60
|
+
fail(error) { drop(state, live, error); } };
|
|
61
|
+
try {
|
|
62
|
+
live.connection = await (state.options.connect || amqp.connect)(state.url, { timeout: state.options.connectTimeoutMs });
|
|
63
|
+
bind(state, live, live.connection);
|
|
64
|
+
if (!await current(state, live)) { return; }
|
|
65
|
+
await configure(state, live);
|
|
66
|
+
if (live.dead) { throw new Error('AMQP session closed during initialization'); }
|
|
67
|
+
await keep(state, live);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await closeLive(live);
|
|
70
|
+
retry(state, epoch);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function begin(state) {
|
|
75
|
+
if (state.live) { return Promise.resolve(); }
|
|
76
|
+
if (state.opening) { return state.opening; }
|
|
77
|
+
if (!state.wanted) { return Promise.resolve(); }
|
|
78
|
+
clearTimeout(state.retry);
|
|
79
|
+
state.retry = null;
|
|
80
|
+
state.opening = connect(state, state.epoch).finally(() => { state.opening = null; });
|
|
81
|
+
return state.opening;
|
|
82
|
+
}
|
package/src/amqpSink.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { begin, closeLive } from './amqpSink/session.js';
|
|
2
|
+
import publication from './amqpSink/publication.js';
|
|
3
|
+
|
|
4
|
+
function settings(url, options) {
|
|
5
|
+
if (typeof url !== 'string' || !/^amqps?:\/\//u.test(url)) { throw new TypeError('AMQP URL is required'); }
|
|
6
|
+
const config = { retryMs: 1000, connectTimeoutMs: 5000, confirmTimeoutMs: 10000, maxPending: 1024, ...options };
|
|
7
|
+
['retryMs', 'connectTimeoutMs', 'confirmTimeoutMs', 'maxPending'].forEach((name) => {
|
|
8
|
+
if (!Number.isFinite(config[name]) || config[name] <= 0) { throw new TypeError(`${name} must be positive`); }
|
|
9
|
+
});
|
|
10
|
+
if (!Number.isInteger(config.maxPending)) { throw new TypeError('maxPending must be an integer'); }
|
|
11
|
+
return config;
|
|
12
|
+
}
|
|
13
|
+
function stop(state) {
|
|
14
|
+
state.request += 1;
|
|
15
|
+
state.epoch += 1;
|
|
16
|
+
state.wanted = false;
|
|
17
|
+
clearTimeout(state.retry);
|
|
18
|
+
state.retry = null;
|
|
19
|
+
const { live } = state;
|
|
20
|
+
state.live = null;
|
|
21
|
+
state.stopped = Promise.all([closeLive(live), state.opening?.catch(() => { return undefined; })]);
|
|
22
|
+
return state.stopped;
|
|
23
|
+
}
|
|
24
|
+
/** AMQP record sink with confirms and independent connection recovery; never automatically replays sends. */
|
|
25
|
+
export default function amqpSink(url, options = {}) {
|
|
26
|
+
const state = {url, options: settings(url, options), wanted: false, epoch: 0, request: 0,
|
|
27
|
+
live: null, opening: null, retry: null, stopped: Promise.resolve(), reopen() { return begin(state); }};
|
|
28
|
+
return {
|
|
29
|
+
active() { return Boolean(state.live); },
|
|
30
|
+
start() {
|
|
31
|
+
state.wanted = true;
|
|
32
|
+
state.request += 1;
|
|
33
|
+
const { request } = state;
|
|
34
|
+
return state.stopped.then(() => { if (request === state.request) { return begin(state); } return undefined; });
|
|
35
|
+
},
|
|
36
|
+
stop() { return stop(state); },
|
|
37
|
+
write(records) {
|
|
38
|
+
if (!Array.isArray(records)) { return Promise.reject(new TypeError('AMQP records must be an array')); }
|
|
39
|
+
if (!state.live) { return Promise.reject(new Error('AMQP sink is not connected')); }
|
|
40
|
+
const { live } = state;
|
|
41
|
+
if (live.blocked || live.pending.size + records.length > state.options.maxPending) {
|
|
42
|
+
return Promise.reject(new Error('AMQP sink backpressure: retry after confirmations or drain'));
|
|
43
|
+
}
|
|
44
|
+
return Promise.all(records.map((record) => { return publication(live, record, state.options.confirmTimeoutMs); }));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Each session is mutated only by its generation; disposal waits for subscription.
|
|
2
|
+
/* eslint-disable require-atomic-updates */
|
|
3
|
+
async function connectBroker(url) {
|
|
4
|
+
const { default: amqp } = await import('amqplib');
|
|
5
|
+
return amqp.connect(url, { heartbeat: 10 });
|
|
6
|
+
}
|
|
7
|
+
function reportFailure(error) {
|
|
8
|
+
process.stderr.write(`amqp consume failed ${error.message}\n`);
|
|
9
|
+
}
|
|
10
|
+
async function closePart(action, report) {
|
|
11
|
+
try { await action(); } catch (error) { report(error); }
|
|
12
|
+
}
|
|
13
|
+
async function closeLive(live, ctx) {
|
|
14
|
+
if (live.tag) { await closePart(() => { return live.channel.cancel(live.tag); }, ctx.report); }
|
|
15
|
+
if (ctx.drain) { await closePart(() => { return ctx.drain(live.channel); }, ctx.report); }
|
|
16
|
+
if (live.channel) { await closePart(() => { return live.channel.close(); }, ctx.report); }
|
|
17
|
+
if (live.connection) { await closePart(() => { return live.connection.close(); }, ctx.report); }
|
|
18
|
+
}
|
|
19
|
+
function dispose(live, ctx) {
|
|
20
|
+
if (!live) { return Promise.resolve(); }
|
|
21
|
+
if (!live.closing) { live.closing = closeLive(live, ctx); }
|
|
22
|
+
return live.closing;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function current(ctx, generation) {
|
|
26
|
+
return ctx.state.running && ctx.state.generation === generation;
|
|
27
|
+
}
|
|
28
|
+
function retry(ctx, generation) {
|
|
29
|
+
if (!current(ctx, generation) || ctx.state.timer !== undefined) { return; }
|
|
30
|
+
const pause = ctx.state.delay;
|
|
31
|
+
ctx.state.delay = Math.min(pause * 2, 30000);
|
|
32
|
+
ctx.state.timer = ctx.schedule(() => {
|
|
33
|
+
ctx.state.timer = undefined;
|
|
34
|
+
ctx.state.opening = ctx.open(generation);
|
|
35
|
+
return ctx.state.opening;
|
|
36
|
+
}, pause);
|
|
37
|
+
}
|
|
38
|
+
function bind(ctx, generation, live, emitter) {
|
|
39
|
+
function lost(error) {
|
|
40
|
+
if (live.lost || !current(ctx, generation)) { return; }
|
|
41
|
+
live.lost = true;
|
|
42
|
+
if (error) { ctx.report(error); }
|
|
43
|
+
ctx.state.cleanup = live.ready.then(() => { return dispose(live, ctx); }).then(() => { retry(ctx, generation); });
|
|
44
|
+
}
|
|
45
|
+
emitter.on('error', lost);
|
|
46
|
+
emitter.on('close', lost);
|
|
47
|
+
}
|
|
48
|
+
async function subscribe(ctx, generation, live) {
|
|
49
|
+
if (!current(ctx, generation) || live.lost) { return; }
|
|
50
|
+
live.channel = await live.connection.createChannel();
|
|
51
|
+
bind(ctx, generation, live, live.channel);
|
|
52
|
+
if (!current(ctx, generation) || live.lost) { return; }
|
|
53
|
+
live.tag = await ctx.subscribe(live.channel);
|
|
54
|
+
}
|
|
55
|
+
async function open(ctx, generation) {
|
|
56
|
+
let complete;
|
|
57
|
+
const live = { closed: false, ready: new Promise((resolve) => { complete = resolve; }) };
|
|
58
|
+
try {
|
|
59
|
+
live.connection = await ctx.connect(ctx.url);
|
|
60
|
+
bind(ctx, generation, live, live.connection);
|
|
61
|
+
await subscribe(ctx, generation, live);
|
|
62
|
+
if (!current(ctx, generation) || live.lost) { await dispose(live, ctx); return; }
|
|
63
|
+
ctx.state.live = live;
|
|
64
|
+
ctx.state.delay = 1000;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
ctx.report(error);
|
|
67
|
+
await dispose(live, ctx);
|
|
68
|
+
retry(ctx, generation);
|
|
69
|
+
} finally { complete(); }
|
|
70
|
+
}
|
|
71
|
+
async function begin(ctx) {
|
|
72
|
+
if (ctx.state.running) { await ctx.state.opening; return; }
|
|
73
|
+
ctx.state.running = true;
|
|
74
|
+
ctx.state.generation += 1;
|
|
75
|
+
const { generation } = ctx.state;
|
|
76
|
+
await ctx.state.stopping;
|
|
77
|
+
await ctx.state.cleanup;
|
|
78
|
+
if (current(ctx, generation)) {
|
|
79
|
+
ctx.state.opening = ctx.open(generation);
|
|
80
|
+
await ctx.state.opening;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function end(ctx) {
|
|
84
|
+
ctx.state.running = false;
|
|
85
|
+
ctx.state.generation += 1;
|
|
86
|
+
if (ctx.state.timer !== undefined) { ctx.clear(ctx.state.timer); ctx.state.timer = undefined; }
|
|
87
|
+
await ctx.state.opening;
|
|
88
|
+
await ctx.state.cleanup;
|
|
89
|
+
await dispose(ctx.state.live, ctx);
|
|
90
|
+
ctx.state.live = undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Shared AMQP session lifecycle; subscribe returns a consumer tag. */
|
|
93
|
+
export default function amqpSession(url, subscribeChannel, options = {}) {
|
|
94
|
+
const ctx = {
|
|
95
|
+
url, subscribe: subscribeChannel, drain: options.drain,
|
|
96
|
+
connect: options.connect || connectBroker,
|
|
97
|
+
schedule: options.schedule || setTimeout,
|
|
98
|
+
clear: options.clear || clearTimeout,
|
|
99
|
+
report: options.onError || options.report || reportFailure,
|
|
100
|
+
state: { generation: 0, running: false, delay: 1000 },
|
|
101
|
+
open(generation) { return open(ctx, generation); }
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
start() { return begin(ctx); },
|
|
105
|
+
stop() {
|
|
106
|
+
ctx.state.stopping = end(ctx);
|
|
107
|
+
return ctx.state.stopping;
|
|
108
|
+
},
|
|
109
|
+
active() { return ctx.state.running; }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import amqpSession from './amqpSource/session.js';
|
|
2
|
+
|
|
3
|
+
function deliveryFor(channel, message) {
|
|
4
|
+
let settled = false;
|
|
5
|
+
return {
|
|
6
|
+
content: message.content, fields: message.fields, properties: message.properties,
|
|
7
|
+
ack() {
|
|
8
|
+
if (!settled) { channel.ack(message); settled = true; }
|
|
9
|
+
},
|
|
10
|
+
reject(requeue = true) {
|
|
11
|
+
if (!settled) { channel.nack(message, false, requeue); settled = true; }
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function reportFailure(options, error) {
|
|
16
|
+
const report = options.onError || options.report;
|
|
17
|
+
try {
|
|
18
|
+
if (report) { report(error); }
|
|
19
|
+
else { process.stderr.write(`amqp delivery failed ${error.message}\n`); }
|
|
20
|
+
} catch {
|
|
21
|
+
// Error reporting must not reject a broker callback.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function rejectDelivery(delivery, options, error) {
|
|
25
|
+
try { delivery.reject(true); }
|
|
26
|
+
catch (settlementError) { reportFailure(options, settlementError); }
|
|
27
|
+
reportFailure(options, error);
|
|
28
|
+
}
|
|
29
|
+
function consume(channel, collector, options) {
|
|
30
|
+
return async (message) => {
|
|
31
|
+
if (!message) { channel.emit('close'); return; }
|
|
32
|
+
const delivery = deliveryFor(channel, message);
|
|
33
|
+
try {
|
|
34
|
+
await collector.accept(delivery);
|
|
35
|
+
if (options.ackMode !== 'manual') { delivery.ack(); }
|
|
36
|
+
} catch (error) {
|
|
37
|
+
rejectDelivery(delivery, options, error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function trackedConsume(channel, collector, options, pending) {
|
|
42
|
+
const accept = consume(channel, collector, options);
|
|
43
|
+
return (message) => {
|
|
44
|
+
const delivery = accept(message);
|
|
45
|
+
pending.add(delivery);
|
|
46
|
+
return delivery.finally(() => { pending.delete(delivery); });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function topology(channel, queue, options) {
|
|
50
|
+
await Promise.all((options.exchanges || []).map((exchange) => {
|
|
51
|
+
return channel.assertExchange(exchange.name, exchange.type, exchange.options);
|
|
52
|
+
}));
|
|
53
|
+
const declared = await channel.assertQueue(queue, options.queueOptions || { durable: true });
|
|
54
|
+
const name = declared?.queue || queue;
|
|
55
|
+
await Promise.all((options.bindings || []).map((binding) => {
|
|
56
|
+
return channel.bindQueue(name, binding.exchange, binding.pattern, binding.args);
|
|
57
|
+
}));
|
|
58
|
+
await channel.prefetch(options.prefetch || 32);
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
/** Delivers AMQP records, acknowledging only after collector acceptance. */
|
|
62
|
+
export default function amqpSource(url, queue, collector, options = {}) {
|
|
63
|
+
if (!collector || typeof collector.accept !== 'function') { throw new Error('Collector must have an accept() method'); }
|
|
64
|
+
const pending = new Set();
|
|
65
|
+
return amqpSession(url, async (channel) => {
|
|
66
|
+
const name = await topology(channel, queue, options);
|
|
67
|
+
const result = await channel.consume(name, trackedConsume(channel, collector, options, pending), { noAck: false });
|
|
68
|
+
return result.consumerTag;
|
|
69
|
+
}, { ...options, drain() { return Promise.all([...pending]); } });
|
|
70
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import positive from './positive.js';
|
|
2
|
+
|
|
3
|
+
function report(onError, error) {
|
|
4
|
+
try { onError?.(error); } catch {
|
|
5
|
+
// Observability must not stop scheduling.
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Serialized periodic ticks. stop() drains acceptance before restart can run. */
|
|
10
|
+
export default function intervalSource(intervalMs, collector, options = {}) {
|
|
11
|
+
positive(intervalMs, 'intervalMs');
|
|
12
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
13
|
+
throw new TypeError('Collector must have an accept() method');
|
|
14
|
+
}
|
|
15
|
+
const { immediate = false, onError, schedule = setTimeout, clear = clearTimeout } = options;
|
|
16
|
+
const state = { active: false, revision: 0, timer: null, pending: Promise.resolve() };
|
|
17
|
+
function tick(revision) {
|
|
18
|
+
if (!state.active || revision !== state.revision) { return; }
|
|
19
|
+
state.timer = null;
|
|
20
|
+
state.pending = Promise.resolve().then(() => {
|
|
21
|
+
if (state.active && revision === state.revision) { return collector.accept({ ts: Date.now() }); }
|
|
22
|
+
return undefined;
|
|
23
|
+
}).catch((error) => { report(onError, error); }).then(() => {
|
|
24
|
+
if (state.active && revision === state.revision) {
|
|
25
|
+
state.timer = schedule(() => { tick(revision); }, intervalMs);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
async start() {
|
|
31
|
+
if (state.active) { return; }
|
|
32
|
+
state.revision += 1;
|
|
33
|
+
const { revision } = state;
|
|
34
|
+
await state.pending;
|
|
35
|
+
if (revision !== state.revision) { return; }
|
|
36
|
+
state.active = true;
|
|
37
|
+
if (immediate) { tick(revision); await state.pending; } else { state.timer = schedule(() => { tick(revision); }, intervalMs); }
|
|
38
|
+
},
|
|
39
|
+
stop() {
|
|
40
|
+
state.active = false;
|
|
41
|
+
state.revision += 1;
|
|
42
|
+
clear(state.timer);
|
|
43
|
+
state.timer = null;
|
|
44
|
+
return state.pending;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/positive.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import positive from './positive.js';
|
|
2
|
+
|
|
3
|
+
function report(options, error, device) {
|
|
4
|
+
try { options.onError?.(error, device); } catch {
|
|
5
|
+
// Observability must not prevent retries.
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
async function openPort(device) {
|
|
9
|
+
const { SerialPort } = await import('serialport');
|
|
10
|
+
const port = new SerialPort({ ...device, autoOpen: false });
|
|
11
|
+
await new Promise((resolve, reject) => { port.open((error) => { if (error) { reject(error); } else { resolve(); } }); });
|
|
12
|
+
return port;
|
|
13
|
+
}
|
|
14
|
+
function closePort(port) {
|
|
15
|
+
if (port.isOpen === false) { return Promise.resolve(); }
|
|
16
|
+
return new Promise((resolve) => { port.close(() => { resolve(); }); });
|
|
17
|
+
}
|
|
18
|
+
function pause(state, milliseconds) {
|
|
19
|
+
return new Promise((resolve) => { state.wake = resolve; state.timer = setTimeout(resolve, milliseconds); })
|
|
20
|
+
.finally(() => { clearTimeout(state.timer); state.wake = null; });
|
|
21
|
+
}
|
|
22
|
+
async function receive(state, port, device, collector, options) {
|
|
23
|
+
const pending = new Set();
|
|
24
|
+
const closed = new Promise((resolve) => { state.wake = resolve; });
|
|
25
|
+
function data(chunk) {
|
|
26
|
+
const accepted = Promise.resolve().then(() => {
|
|
27
|
+
if (state.active) { return collector.accept({ device, chunk }); }
|
|
28
|
+
return undefined;
|
|
29
|
+
}).catch((error) => { report(options, error, device); }).finally(() => { pending.delete(accepted); });
|
|
30
|
+
pending.add(accepted);
|
|
31
|
+
}
|
|
32
|
+
function failed(failure) { report(options, failure, device); state.wake?.(); }
|
|
33
|
+
function close() { state.wake?.(); }
|
|
34
|
+
port.on('data', data).on('error', failed).on('close', close);
|
|
35
|
+
if (!state.active) { state.wake(); }
|
|
36
|
+
await closed;
|
|
37
|
+
port.off('data', data);
|
|
38
|
+
try { await closePort(port); } finally {
|
|
39
|
+
port.off('error', failed).off('close', close);
|
|
40
|
+
await Promise.all(pending);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function run(state, device, collector, options) {
|
|
44
|
+
while (state.active) {
|
|
45
|
+
try {
|
|
46
|
+
// Each port has an independent serialized open/close/retry loop.
|
|
47
|
+
// eslint-disable-next-line no-await-in-loop
|
|
48
|
+
const port = await options.openPort(device);
|
|
49
|
+
// eslint-disable-next-line no-await-in-loop
|
|
50
|
+
await receive(state, port, device, collector, options);
|
|
51
|
+
} catch (error) { if (state.active) { report(options, error, device); } }
|
|
52
|
+
if (state.active) {
|
|
53
|
+
// eslint-disable-next-line no-await-in-loop
|
|
54
|
+
await pause(state, options.retryMs);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function validate(devices, collector) {
|
|
59
|
+
if (!Array.isArray(devices)) { throw new TypeError('devices must be an array'); }
|
|
60
|
+
if (!collector || typeof collector.accept !== 'function') { throw new TypeError('Collector must have an accept() method'); }
|
|
61
|
+
const paths = new Set();
|
|
62
|
+
devices.forEach((device) => {
|
|
63
|
+
if (!device || typeof device.path !== 'string' || !device.path.trim() || paths.has(device.path)) {
|
|
64
|
+
throw new TypeError('invalid or duplicate serial path');
|
|
65
|
+
}
|
|
66
|
+
paths.add(device.path);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Independent serial devices; chunks and device metadata pass through unchanged. */
|
|
70
|
+
export default function serialSource(devices, collector, options = {}) {
|
|
71
|
+
validate(devices, collector);
|
|
72
|
+
const settings = { retryMs: 1000, ...options, openPort: options.openPort ?? openPort };
|
|
73
|
+
positive(settings.retryMs, 'retryMs');
|
|
74
|
+
const states = devices.map(() => { return { active: false, task: null, wake: null, timer: null }; });
|
|
75
|
+
let revision = 0;
|
|
76
|
+
let stopping = Promise.resolve();
|
|
77
|
+
return {
|
|
78
|
+
async start() {
|
|
79
|
+
revision += 1;
|
|
80
|
+
const requested = revision;
|
|
81
|
+
await stopping;
|
|
82
|
+
if (requested === revision) {
|
|
83
|
+
states.forEach((state, index) => {
|
|
84
|
+
if (!state.active) { state.active = true; state.task = run(state, devices[index], collector, settings); }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
stop() {
|
|
89
|
+
revision += 1;
|
|
90
|
+
states.forEach((state) => { state.active = false; state.wake?.(); });
|
|
91
|
+
stopping = Promise.all(states.map((state) => { return state.task; }));
|
|
92
|
+
return stopping;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/stompSource.js
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { GenericContainer, Wait } from 'testcontainers';
|
|
3
|
+
import amqp from 'amqplib';
|
|
4
|
+
import amqpSink from '../src/amqpSink.js';
|
|
5
|
+
import amqpSource from '../src/amqpSource.js';
|
|
6
|
+
|
|
7
|
+
function pause(ms) {
|
|
8
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
9
|
+
}
|
|
10
|
+
async function until(probe, timeout = 20000) {
|
|
11
|
+
const deadline = Date.now() + timeout;
|
|
12
|
+
while (!await probe()) {
|
|
13
|
+
if (Date.now() >= deadline) { throw new Error('AMQP recovery condition timed out'); }
|
|
14
|
+
await pause(100);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('AMQP source and sink integration', function() {
|
|
19
|
+
let container;
|
|
20
|
+
let url;
|
|
21
|
+
let source;
|
|
22
|
+
let sink;
|
|
23
|
+
before(async function() {
|
|
24
|
+
this.timeout(120000);
|
|
25
|
+
container = await new GenericContainer('rabbitmq:3.13-management')
|
|
26
|
+
.withEnvironment({ RABBITMQ_DEFAULT_USER: 'streams', RABBITMQ_DEFAULT_PASS: 'streams' })
|
|
27
|
+
.withExposedPorts(5672)
|
|
28
|
+
.withWaitStrategy(Wait.forListeningPorts())
|
|
29
|
+
.withStartupTimeout(90000).start();
|
|
30
|
+
url = `amqp://streams:streams@${container.getHost()}:${container.getMappedPort(5672)}`;
|
|
31
|
+
});
|
|
32
|
+
after(async function() {
|
|
33
|
+
this.timeout(30000);
|
|
34
|
+
await source?.stop();
|
|
35
|
+
await sink?.stop();
|
|
36
|
+
await container?.stop();
|
|
37
|
+
});
|
|
38
|
+
it('awaits acceptance and restores exactly one consumer and confirmed publishing after broker restart', async function() {
|
|
39
|
+
this.timeout(60000);
|
|
40
|
+
const received = [];
|
|
41
|
+
let release;
|
|
42
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
43
|
+
const exchange = 'streams.integration';
|
|
44
|
+
const queue = 'streams.integration.queue';
|
|
45
|
+
const exchanges = [{ name: exchange, type: 'direct', options: { durable: true } }];
|
|
46
|
+
source = amqpSource(url, queue, { async accept(delivery) {
|
|
47
|
+
const body = delivery.content.toString();
|
|
48
|
+
received.push(body);
|
|
49
|
+
if (body === 'first') { await gate; }
|
|
50
|
+
} }, { exchanges, bindings: [{ exchange, pattern: 'record' }], prefetch: 1, onError: () => {} });
|
|
51
|
+
sink = amqpSink(url, { exchanges, retryMs: 100, onError: () => {} });
|
|
52
|
+
await source.start();
|
|
53
|
+
await sink.start();
|
|
54
|
+
await sink.write(['first', 'second'].map((body) => {
|
|
55
|
+
return { exchange, routingKey: 'record', body, options: { persistent: true } };
|
|
56
|
+
}));
|
|
57
|
+
await until(() => { return received.length === 1; });
|
|
58
|
+
await pause(200);
|
|
59
|
+
assert.deepEqual(received, ['first'], 'prefetch credit returned before acceptance');
|
|
60
|
+
release();
|
|
61
|
+
await until(() => { return received.length === 2; });
|
|
62
|
+
const down = await container.exec(['rabbitmqctl', 'stop_app']);
|
|
63
|
+
assert.equal(down.exitCode, 0, down.output);
|
|
64
|
+
await until(() => { return !sink.active(); });
|
|
65
|
+
const up = await container.exec(['rabbitmqctl', 'start_app']);
|
|
66
|
+
assert.equal(up.exitCode, 0, up.output);
|
|
67
|
+
await until(() => { return sink.active(); });
|
|
68
|
+
const probe = await amqp.connect(url);
|
|
69
|
+
try {
|
|
70
|
+
const channel = await probe.createChannel();
|
|
71
|
+
await until(async () => { return (await channel.checkQueue(queue)).consumerCount === 1; });
|
|
72
|
+
await sink.write([{ exchange, routingKey: 'record', body: 'recovered' }]);
|
|
73
|
+
await until(() => { return received.length >= 3; });
|
|
74
|
+
await pause(200);
|
|
75
|
+
assert.deepEqual(received, ['first', 'second', 'recovered']);
|
|
76
|
+
assert.equal((await channel.checkQueue(queue)).consumerCount, 1);
|
|
77
|
+
} finally { await probe.close(); }
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import intervalSource from '../src/intervalSource.js';
|
|
3
|
+
const turn = () => { return new Promise((resolve) => { setImmediate(resolve); }); };
|
|
4
|
+
describe('intervalSource', function () {
|
|
5
|
+
it('serializes acceptance across stop and restart and cancels stale callbacks', async function () {
|
|
6
|
+
const scheduled = [];
|
|
7
|
+
const cleared = [];
|
|
8
|
+
let release;
|
|
9
|
+
const events = [];
|
|
10
|
+
const source = intervalSource(10, { accept(event) { events.push(event); return new Promise((resolve) => { release = resolve; }); } }, {
|
|
11
|
+
schedule(callback) { scheduled.push(callback); return callback; }, clear(handle) { cleared.push(handle); }
|
|
12
|
+
});
|
|
13
|
+
await source.start();
|
|
14
|
+
const old = scheduled.shift();
|
|
15
|
+
old();
|
|
16
|
+
await turn();
|
|
17
|
+
assert.equal(events.length, 1);
|
|
18
|
+
assert.equal(typeof events[0].ts, 'number');
|
|
19
|
+
assert.equal(scheduled.length, 0);
|
|
20
|
+
const stopped = source.stop();
|
|
21
|
+
const restarted = source.start();
|
|
22
|
+
old();
|
|
23
|
+
await turn();
|
|
24
|
+
assert.equal(events.length, 1);
|
|
25
|
+
release();
|
|
26
|
+
await Promise.all([stopped, restarted]);
|
|
27
|
+
assert.equal(scheduled.length, 1);
|
|
28
|
+
const stale = scheduled.shift();
|
|
29
|
+
await source.stop();
|
|
30
|
+
stale();
|
|
31
|
+
await turn();
|
|
32
|
+
assert.equal(events.length, 1);
|
|
33
|
+
assert.ok(cleared.includes(stale));
|
|
34
|
+
});
|
|
35
|
+
it('awaits the immediate acceptance before start resolves', async function () {
|
|
36
|
+
let release;
|
|
37
|
+
let started = false;
|
|
38
|
+
const source = intervalSource(1000, { accept() { return new Promise((resolve) => { release = resolve; }); } }, { immediate: true });
|
|
39
|
+
const starting = source.start().then(() => { started = true; });
|
|
40
|
+
await turn();
|
|
41
|
+
try {
|
|
42
|
+
assert.equal(started, false);
|
|
43
|
+
} finally {
|
|
44
|
+
release();
|
|
45
|
+
await starting;
|
|
46
|
+
await source.stop();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
it('reports rejected ticks and keeps scheduling', async function () {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const scheduled = [];
|
|
52
|
+
const failure = new Error('sink unavailable');
|
|
53
|
+
const source = intervalSource(10, { accept() { return Promise.reject(failure); } }, {
|
|
54
|
+
immediate: true, onError(error) { errors.push(error); }, schedule(callback) { scheduled.push(callback); }, clear() {}
|
|
55
|
+
});
|
|
56
|
+
await source.start();
|
|
57
|
+
await turn();
|
|
58
|
+
assert.deepEqual(errors, [failure]);
|
|
59
|
+
assert.equal(scheduled.length, 1);
|
|
60
|
+
await source.stop();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import serialSource from '../src/serialSource.js';
|
|
4
|
+
const pause = (ms = 10) => { return new Promise((resolve) => { setTimeout(resolve, ms); }); };
|
|
5
|
+
function port() { const result = new EventEmitter(); result.close = (callback) => { result.emit('close'); callback(); }; return result; }
|
|
6
|
+
describe('serialSource', function () {
|
|
7
|
+
it('rejects duplicate paths', function () { assert.throws(() => { serialSource([{ path: '/a' }, { path: '/a' }], { accept() {} }); }, /duplicate/u); });
|
|
8
|
+
it('isolates unavailable devices, retries close, and removes old listeners across restart', async function () {
|
|
9
|
+
const devices = [{ path: '/a' }, { path: '/b' }];
|
|
10
|
+
const opened = [];
|
|
11
|
+
const events = [];
|
|
12
|
+
const errors = [];
|
|
13
|
+
let unavailable = true;
|
|
14
|
+
const source = serialSource(devices, { accept(event) { events.push(event); } }, { retryMs: 5,
|
|
15
|
+
onError(error) { errors.push(error); }, openPort(device) { if (device.path === '/a' && unavailable) { throw new Error('unavailable'); } const item = port(); opened.push({ device, port: item }); return item; }
|
|
16
|
+
});
|
|
17
|
+
await source.start();
|
|
18
|
+
await source.start();
|
|
19
|
+
await pause();
|
|
20
|
+
const healthy = opened.find(({ device }) => { return device.path === '/b'; }).port;
|
|
21
|
+
const chunk = Buffer.from('raw');
|
|
22
|
+
healthy.emit('data', chunk);
|
|
23
|
+
await pause();
|
|
24
|
+
assert.equal(events[0].device, devices[1]);
|
|
25
|
+
assert.equal(events[0].chunk, chunk);
|
|
26
|
+
assert.ok(errors.length > 0);
|
|
27
|
+
unavailable = false;
|
|
28
|
+
healthy.emit('close');
|
|
29
|
+
await pause(30);
|
|
30
|
+
assert.equal(healthy.listenerCount('data'), 0);
|
|
31
|
+
assert.equal(opened.filter(({ device }) => { return device.path === '/b'; }).length, 2);
|
|
32
|
+
await source.stop();
|
|
33
|
+
assert.ok(opened.every(({ port: item }) => { return item.listenerCount('data') === 0; }));
|
|
34
|
+
const count = events.length;
|
|
35
|
+
healthy.emit('data', chunk);
|
|
36
|
+
await source.start();
|
|
37
|
+
await pause();
|
|
38
|
+
assert.equal(events.length, count);
|
|
39
|
+
assert.equal(opened.filter(({ device }) => { return device.path === '/b'; }).length, 3);
|
|
40
|
+
await source.stop();
|
|
41
|
+
});
|
|
42
|
+
it('removes listeners even when a custom port throws while closing', async function () {
|
|
43
|
+
const item = port();
|
|
44
|
+
item.close = () => { throw new Error('close failed'); };
|
|
45
|
+
const source = serialSource([{ path: '/a' }], { accept() {} }, { openPort() { return item; } });
|
|
46
|
+
await source.start();
|
|
47
|
+
await pause();
|
|
48
|
+
await source.stop();
|
|
49
|
+
assert.equal(item.listenerCount('data'), 0);
|
|
50
|
+
assert.equal(item.listenerCount('error'), 0);
|
|
51
|
+
assert.equal(item.listenerCount('close'), 0);
|
|
52
|
+
});
|
|
53
|
+
it('closes a port that finishes opening after stop and cancels immediate start', async function () {
|
|
54
|
+
let finish;
|
|
55
|
+
const item = port();
|
|
56
|
+
const source = serialSource([{ path: '/a' }], { accept() { assert.fail('late delivery'); } }, { openPort() { return new Promise((resolve) => { finish = resolve; }); } });
|
|
57
|
+
await source.start();
|
|
58
|
+
await pause();
|
|
59
|
+
const stopping = source.stop();
|
|
60
|
+
finish(item);
|
|
61
|
+
await stopping;
|
|
62
|
+
assert.equal(item.listenerCount('data'), 0);
|
|
63
|
+
const starting = source.start();
|
|
64
|
+
await source.stop();
|
|
65
|
+
await starting;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import amqpSink from '../src/amqpSink.js';
|
|
4
|
+
|
|
5
|
+
function broker() {
|
|
6
|
+
const sent = [];
|
|
7
|
+
const confirmations = [];
|
|
8
|
+
const channel = Object.assign(new EventEmitter(), {
|
|
9
|
+
assertExchange(...args) { sent.push(['exchange', ...args]); return Promise.resolve(); },
|
|
10
|
+
publish(exchange, routingKey, body, options, confirmed) {
|
|
11
|
+
sent.push(['publish', exchange, routingKey, body.toString(), options]);
|
|
12
|
+
confirmations.push(confirmed);
|
|
13
|
+
return true;
|
|
14
|
+
},
|
|
15
|
+
close() { channel.emit('close'); return Promise.resolve(); }
|
|
16
|
+
});
|
|
17
|
+
const connection = Object.assign(new EventEmitter(), {
|
|
18
|
+
createConfirmChannel() { return Promise.resolve(channel); },
|
|
19
|
+
close() { connection.emit('close'); return Promise.resolve(); }
|
|
20
|
+
});
|
|
21
|
+
return { connection, channel, sent, confirmations };
|
|
22
|
+
}
|
|
23
|
+
const record = {exchange: 'events', routingKey: 'line.temperature', body: '{"value":42}',
|
|
24
|
+
options: {persistent: true, contentType: 'application/json'}};
|
|
25
|
+
|
|
26
|
+
describe('amqpSink', function() {
|
|
27
|
+
it('declares explicit exchanges and waits for broker confirmation', async function() {
|
|
28
|
+
const fixture = broker();
|
|
29
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); },
|
|
30
|
+
exchanges: [{name: 'events', type: 'topic', options: {durable: true}}]});
|
|
31
|
+
await sink.start();
|
|
32
|
+
let done = false;
|
|
33
|
+
const written = sink.write([record]).then(() => { done = true; });
|
|
34
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
35
|
+
assert.equal(done, false);
|
|
36
|
+
assert.deepEqual(fixture.sent, [['exchange', 'events', 'topic', {durable: true}],
|
|
37
|
+
['publish', 'events', 'line.temperature', record.body, record.options]]);
|
|
38
|
+
fixture.confirmations[0](null);
|
|
39
|
+
await written;
|
|
40
|
+
assert.equal(done, true);
|
|
41
|
+
await sink.stop();
|
|
42
|
+
});
|
|
43
|
+
it('rejects failed confirms without replaying an uncertain publication', async function() {
|
|
44
|
+
const fixture = broker();
|
|
45
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); }});
|
|
46
|
+
await sink.start();
|
|
47
|
+
const written = sink.write([record]);
|
|
48
|
+
fixture.confirmations[0](new Error('nack'));
|
|
49
|
+
await assert.rejects(written, /nack/u);
|
|
50
|
+
assert.equal(fixture.sent.length, 1);
|
|
51
|
+
await sink.stop();
|
|
52
|
+
});
|
|
53
|
+
it('rejects offline writes and does not open duplicate sessions on repeated start', async function() {
|
|
54
|
+
const fixture = broker();
|
|
55
|
+
let connects = 0;
|
|
56
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { connects += 1; return Promise.resolve(fixture.connection); }});
|
|
57
|
+
await assert.rejects(sink.write([record]), /not connected/u);
|
|
58
|
+
await Promise.all([sink.start(), sink.start()]);
|
|
59
|
+
assert.equal(connects, 1);
|
|
60
|
+
assert.equal(sink.active(), true);
|
|
61
|
+
await sink.stop();
|
|
62
|
+
assert.equal(sink.active(), false);
|
|
63
|
+
});
|
|
64
|
+
it('closes a connection whose channel initialization failed', async function() {
|
|
65
|
+
const fixture = broker();
|
|
66
|
+
let closed = 0;
|
|
67
|
+
fixture.connection.createConfirmChannel = () => { return Promise.reject(new Error('channel failed')); };
|
|
68
|
+
fixture.connection.close = () => { closed += 1; return Promise.resolve(); };
|
|
69
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); }, retryMs: 10000});
|
|
70
|
+
await assert.rejects(sink.start(), /channel failed/u);
|
|
71
|
+
await sink.stop();
|
|
72
|
+
assert.equal(closed, 1);
|
|
73
|
+
});
|
|
74
|
+
it('ignores a late connection after stop and supports a clean restart', async function() {
|
|
75
|
+
let resolveConnection;
|
|
76
|
+
const fixture = broker();
|
|
77
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return new Promise((resolve) => { resolveConnection = resolve; }); }});
|
|
78
|
+
const starting = sink.start();
|
|
79
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
80
|
+
const stopped = sink.stop();
|
|
81
|
+
resolveConnection(fixture.connection);
|
|
82
|
+
await Promise.all([starting, stopped]);
|
|
83
|
+
assert.equal(sink.active(), false);
|
|
84
|
+
});
|
|
85
|
+
it('refuses more sends while transport is backpressured and resumes on drain', async function() {
|
|
86
|
+
const fixture = broker();
|
|
87
|
+
fixture.channel.publish = (...args) => { fixture.confirmations.push(args.at(-1)); return false; };
|
|
88
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); }});
|
|
89
|
+
await sink.start();
|
|
90
|
+
const first = sink.write([record]);
|
|
91
|
+
await assert.rejects(sink.write([record]), /backpressure/u);
|
|
92
|
+
fixture.confirmations[0](null);
|
|
93
|
+
await first;
|
|
94
|
+
fixture.channel.emit('drain');
|
|
95
|
+
const second = sink.write([record]);
|
|
96
|
+
fixture.confirmations[1](null);
|
|
97
|
+
await second;
|
|
98
|
+
await sink.stop();
|
|
99
|
+
});
|
|
100
|
+
it('retires a session after confirmation timeout without replaying its records', async function() {
|
|
101
|
+
const fixture = broker();
|
|
102
|
+
let closed = 0;
|
|
103
|
+
fixture.connection.close = () => { closed += 1; return Promise.resolve(); };
|
|
104
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); },
|
|
105
|
+
confirmTimeoutMs: 15, retryMs: 10000});
|
|
106
|
+
await sink.start();
|
|
107
|
+
await assert.rejects(sink.write([record]), /timed out/u);
|
|
108
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
109
|
+
assert.equal(sink.active(), false);
|
|
110
|
+
assert.equal(closed, 1);
|
|
111
|
+
assert.equal(fixture.sent.length, 1);
|
|
112
|
+
await sink.stop();
|
|
113
|
+
});
|
|
114
|
+
it('bounds the number of outstanding confirmations', async function() {
|
|
115
|
+
const fixture = broker();
|
|
116
|
+
const sink = amqpSink('amqp://localhost', {connect: () => { return Promise.resolve(fixture.connection); }, maxPending: 1});
|
|
117
|
+
await sink.start();
|
|
118
|
+
const first = sink.write([record]);
|
|
119
|
+
await assert.rejects(sink.write([record]), /backpressure/u);
|
|
120
|
+
fixture.confirmations[0](null);
|
|
121
|
+
await first;
|
|
122
|
+
await sink.stop();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* eslint-disable require-await */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import amqpSource from '../src/amqpSource.js';
|
|
5
|
+
|
|
6
|
+
function broker() {
|
|
7
|
+
const events = [];
|
|
8
|
+
const channel = new EventEmitter();
|
|
9
|
+
const connection = new EventEmitter();
|
|
10
|
+
Object.assign(channel, {
|
|
11
|
+
assertQueue: async () => { events.push('queue'); },
|
|
12
|
+
prefetch: async () => {},
|
|
13
|
+
consume: async (queue, callback) => { channel.deliver = callback; return { consumerTag: 'tag' }; },
|
|
14
|
+
ack: () => { events.push('ack'); },
|
|
15
|
+
nack: (msg, all, requeue) => { events.push(['reject', requeue]); },
|
|
16
|
+
cancel: async () => { events.push('cancel'); },
|
|
17
|
+
close: async () => { events.push('channel.close'); }
|
|
18
|
+
});
|
|
19
|
+
connection.createChannel = async () => { return channel; };
|
|
20
|
+
connection.close = async () => { events.push('connection.close'); };
|
|
21
|
+
return { connection, channel, events };
|
|
22
|
+
}
|
|
23
|
+
const message = { content: Buffer.from('value'), fields: {}, properties: {} };
|
|
24
|
+
describe('amqpSource', () => {
|
|
25
|
+
it('acknowledges only after asynchronous acceptance', async () => {
|
|
26
|
+
const fake = broker();
|
|
27
|
+
let release;
|
|
28
|
+
const source = amqpSource('amqp://test', 'queue', { accept: () => { return new Promise((resolve) => { release = resolve; }); } }, { connect: async () => { return fake.connection; } });
|
|
29
|
+
await source.start();
|
|
30
|
+
const pending = fake.channel.deliver(message);
|
|
31
|
+
assert.deepEqual(fake.events, ['queue']);
|
|
32
|
+
release();
|
|
33
|
+
await pending;
|
|
34
|
+
assert.deepEqual(fake.events, ['queue', 'ack']);
|
|
35
|
+
await source.stop();
|
|
36
|
+
});
|
|
37
|
+
it('requeues acceptance failures and does not double settle explicit rejects', async () => {
|
|
38
|
+
const fake = broker();
|
|
39
|
+
const errors = [];
|
|
40
|
+
const source = amqpSource('amqp://test', 'queue', { accept: async (delivery) => { delivery.reject(false); throw new Error('bad record'); } }, { connect: async () => { return fake.connection; }, onError: (error) => { errors.push(error.message); } });
|
|
41
|
+
await source.start();
|
|
42
|
+
await fake.channel.deliver(message);
|
|
43
|
+
assert.deepEqual(fake.events, ['queue', ['reject', false]]);
|
|
44
|
+
assert.deepEqual(errors, ['bad record']);
|
|
45
|
+
await source.stop();
|
|
46
|
+
});
|
|
47
|
+
it('closes a partially subscribed session before retrying', async () => {
|
|
48
|
+
const fake = broker();
|
|
49
|
+
fake.channel.assertQueue = async () => { throw new Error('queue failure'); };
|
|
50
|
+
const callbacks = [];
|
|
51
|
+
const source = amqpSource('amqp://test', 'queue', { accept: () => {} }, { connect: async () => { return fake.connection; }, onError: () => {}, schedule: (callback) => { callbacks.push(callback); return 1; }, clear: () => {} });
|
|
52
|
+
await source.start();
|
|
53
|
+
assert.deepEqual(fake.events, ['channel.close', 'connection.close']);
|
|
54
|
+
assert.equal(callbacks.length, 1);
|
|
55
|
+
await source.stop();
|
|
56
|
+
});
|
|
57
|
+
it('closes a lost session once before scheduling one replacement', async () => {
|
|
58
|
+
const fake = broker();
|
|
59
|
+
const callbacks = [];
|
|
60
|
+
const source = amqpSource('amqp://test', 'queue', { accept: () => {} }, { connect: async () => { return fake.connection; }, schedule: (callback) => { callbacks.push(callback); return 1; }, clear: () => {}, onError: () => {} });
|
|
61
|
+
await source.start();
|
|
62
|
+
fake.channel.emit('error', new Error('lost'));
|
|
63
|
+
fake.connection.emit('close');
|
|
64
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
65
|
+
assert.deepEqual(fake.events, ['queue', 'cancel', 'channel.close', 'connection.close']);
|
|
66
|
+
assert.equal(callbacks.length, 1);
|
|
67
|
+
await source.stop();
|
|
68
|
+
});
|
|
69
|
+
it('requeues failed asynchronous acceptance', async () => {
|
|
70
|
+
const fake = broker();
|
|
71
|
+
const source = amqpSource('amqp://test', 'queue', { accept: async () => { throw new Error('unavailable'); } }, { connect: async () => { return fake.connection; }, onError: () => {} });
|
|
72
|
+
await source.start();
|
|
73
|
+
await fake.channel.deliver(message);
|
|
74
|
+
assert.deepEqual(fake.events, ['queue', ['reject', true]]);
|
|
75
|
+
await source.stop();
|
|
76
|
+
});
|
|
77
|
+
it('restart waits for the old connection to finish closing', async () => {
|
|
78
|
+
const first = broker();
|
|
79
|
+
const second = broker();
|
|
80
|
+
let close;
|
|
81
|
+
first.connection.close = () => { return new Promise((resolve) => { close = resolve; }); };
|
|
82
|
+
let count = 0;
|
|
83
|
+
const source = amqpSource('amqp://test', 'queue', { accept: () => {} }, { connect: async () => { count += 1; return count === 1 ? first.connection : second.connection; } });
|
|
84
|
+
await source.start();
|
|
85
|
+
const stopped = source.stop();
|
|
86
|
+
const started = source.start();
|
|
87
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
88
|
+
assert.equal(count, 1);
|
|
89
|
+
close();
|
|
90
|
+
await stopped;
|
|
91
|
+
await started;
|
|
92
|
+
assert.equal(count, 2);
|
|
93
|
+
await source.stop();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports settlement failure without rejecting the broker callback', async () => {
|
|
97
|
+
const fake = broker();
|
|
98
|
+
const errors = [];
|
|
99
|
+
fake.channel.ack = () => { throw new Error('channel closed'); };
|
|
100
|
+
fake.channel.nack = () => { throw new Error('channel closed'); };
|
|
101
|
+
const source = amqpSource('amqp://test', 'queue', { accept: () => {} }, {
|
|
102
|
+
connect: () => { return Promise.resolve(fake.connection); },
|
|
103
|
+
onError: (error) => { errors.push(error.message); }
|
|
104
|
+
});
|
|
105
|
+
await source.start();
|
|
106
|
+
await assert.doesNotReject(fake.channel.deliver(message));
|
|
107
|
+
assert.ok(errors.includes('channel closed'));
|
|
108
|
+
await source.stop();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('cancels consumption then drains acceptance before closing or restarting', async () => {
|
|
112
|
+
const first = broker();
|
|
113
|
+
const second = broker();
|
|
114
|
+
let release;
|
|
115
|
+
let connects = 0;
|
|
116
|
+
const source = amqpSource('amqp://test', 'queue', {
|
|
117
|
+
accept: () => { return new Promise((resolve) => { release = resolve; }); }
|
|
118
|
+
}, { connect: () => { connects += 1; return Promise.resolve(connects === 1 ? first.connection : second.connection); } });
|
|
119
|
+
await source.start();
|
|
120
|
+
const delivery = first.channel.deliver(message);
|
|
121
|
+
let stopped = false;
|
|
122
|
+
const stopping = source.stop().then(() => { stopped = true; });
|
|
123
|
+
const restarting = source.start();
|
|
124
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
125
|
+
const observed = { stopped, connects, events: [...first.events] };
|
|
126
|
+
release();
|
|
127
|
+
await Promise.all([delivery, stopping, restarting]);
|
|
128
|
+
await source.stop();
|
|
129
|
+
assert.deepEqual(observed, { stopped: false, connects: 1, events: ['queue', 'cancel'] });
|
|
130
|
+
assert.deepEqual(first.events, ['queue', 'cancel', 'ack', 'channel.close', 'connection.close']);
|
|
131
|
+
});
|
|
132
|
+
it('drains acceptance on connection loss before scheduling recovery', async () => {
|
|
133
|
+
const fake = broker();
|
|
134
|
+
let release;
|
|
135
|
+
const retries = [];
|
|
136
|
+
const source = amqpSource('amqp://test', 'queue', {
|
|
137
|
+
accept: () => { return new Promise((resolve) => { release = resolve; }); }
|
|
138
|
+
}, { connect: () => { return Promise.resolve(fake.connection); },
|
|
139
|
+
schedule: (callback) => { retries.push(callback); return 1; }, clear: () => {} });
|
|
140
|
+
await source.start();
|
|
141
|
+
const delivery = fake.channel.deliver(message);
|
|
142
|
+
fake.connection.emit('close');
|
|
143
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
144
|
+
const observed = { retries: retries.length, events: [...fake.events] };
|
|
145
|
+
release();
|
|
146
|
+
await delivery;
|
|
147
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
148
|
+
await source.stop();
|
|
149
|
+
assert.deepEqual(observed, { retries: 0, events: ['queue', 'cancel'] });
|
|
150
|
+
assert.equal(retries.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
});
|
package/test/test_stompSource.js
CHANGED
|
@@ -114,3 +114,33 @@ describe('STOMP delivery window', function() {
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
describe('STOMP dead lettering', function() {
|
|
120
|
+
it('sends requeue=false for a rejected poison message', async function() {
|
|
121
|
+
const original = stompit.Channel;
|
|
122
|
+
let receive;
|
|
123
|
+
let headers;
|
|
124
|
+
stompit.Channel = function Channel() {
|
|
125
|
+
return {
|
|
126
|
+
subscribe(value, callback) { receive = callback; },
|
|
127
|
+
nack(message, value) { headers = value; },
|
|
128
|
+
close() {}
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
const source = stompSource('stomp://localhost:61613', '/queue/test', {
|
|
133
|
+
accept({ settle }) { settle.nack(false); }
|
|
134
|
+
}, { manualAck: true });
|
|
135
|
+
source.start();
|
|
136
|
+
receive(null, { readString(encoding, callback) { callback(null, '{}'); } });
|
|
137
|
+
await new Promise((resolve) => { setImmediate(resolve); });
|
|
138
|
+
source.stop();
|
|
139
|
+
assert.deepStrictEqual(headers, { requeue: 'false' });
|
|
140
|
+
} finally {
|
|
141
|
+
// Test restores its synchronous module stub after delivery completes.
|
|
142
|
+
// eslint-disable-next-line require-atomic-updates
|
|
143
|
+
stompit.Channel = original;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|