@yarkivaev/source-to-sink 1.3.8 → 1.3.10

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 CHANGED
@@ -38,3 +38,14 @@ source.start();
38
38
  ## License
39
39
 
40
40
  MIT
41
+
42
+ ### STOMP delivery window
43
+
44
+ `stompSource(url, destination, collector, { prefetch: 32 })` bounds broker deliveries
45
+ that are waiting for acknowledgement. The default is 32; use a positive safe
46
+ integer to tune throughput. A zero (unlimited) window is rejected because a
47
+ backlog can otherwise exhaust the consumer's heap. With `manualAck: true`, the
48
+ collector remains responsible for settling frames. If it flushes only by count,
49
+ its batch must not exceed the prefetch window; a timed flush also avoids stalls.
50
+
51
+ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/source-to-sink",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,8 +62,8 @@ function settleOf(channel, message) {
62
62
  ack() {
63
63
  channel.ack(message);
64
64
  },
65
- nack() {
66
- channel.nack(message);
65
+ nack(requeue = true) {
66
+ channel.nack(message, { requeue: String(requeue) });
67
67
  }
68
68
  };
69
69
  }
@@ -115,13 +115,13 @@ function read(message) {
115
115
  * @param {object} channel - STOMP channel
116
116
  * @param {string} destination - queue destination
117
117
  * @param {object} collector - message collector
118
- * @param {boolean} serial - wait for persist before the next arrival
119
- * @param {boolean} manualAck - collector settles frames
118
+ * @param {object} delivery - serial, manualAck and prefetch settings
120
119
  * @returns {undefined}
121
120
  */
122
- function bind(channel, destination, collector, serial, manualAck) {
121
+ function bind(channel, destination, collector, delivery) {
122
+ const { serial, manualAck, prefetch } = delivery;
123
123
  let pending = Promise.resolve();
124
- channel.subscribe({ destination, ack: 'client-individual' }, (err, message) => {
124
+ channel.subscribe({ destination, ack: 'client-individual', 'prefetch-count': String(prefetch) }, (err, message) => {
125
125
  if (err) {
126
126
  return;
127
127
  }
@@ -140,11 +140,13 @@ function bind(channel, destination, collector, serial, manualAck) {
140
140
  * Messages are `{destination, payload, settle}`. Default ack-after-accept;
141
141
  * with `manualAck` the collector owns settle. Arrivals enqueue in
142
142
  * subscribe order. With `serial: false` persist may overlap after enqueue.
143
+ * A finite prefetch (default 32) bounds unacknowledged broker deliveries.
143
144
  *
144
145
  * @param {string} url - STOMP broker URL
145
146
  * @param {string} destination - STOMP destination
146
147
  * @param {object} collector - Collector with accept()
147
148
  * @param {object} [options] - Connection and delivery options
149
+ * @param {number} [options.prefetch=32] - Positive integer delivery window
148
150
  * @returns {object} Source with start() and stop() methods
149
151
  */
150
152
  export default function stompSource(url, destination, collector, options = {}) {
@@ -157,6 +159,10 @@ export default function stompSource(url, destination, collector, options = {}) {
157
159
  if (!collector || typeof collector.accept !== 'function') {
158
160
  throw new Error('Collector must have an accept() method');
159
161
  }
162
+ const prefetch = options.prefetch ?? 32;
163
+ if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
164
+ throw new Error('prefetch must be a positive safe integer');
165
+ }
160
166
  const parsed = new URL(url);
161
167
  const serial = options.serial !== false;
162
168
  const manualAck = options.manualAck === true;
@@ -172,7 +178,7 @@ export default function stompSource(url, destination, collector, options = {}) {
172
178
  maxReconnects: -1
173
179
  });
174
180
  const channel = new stompit.Channel(failover);
175
- bind(channel, destination, collector, serial, manualAck);
181
+ bind(channel, destination, collector, { serial, manualAck, prefetch });
176
182
  state = subscribed(channel);
177
183
  },
178
184
  stop() {
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { describe, it } from 'mocha';
3
3
  import stompSource from '../src/stompSource.js';
4
+ import stompit from 'stompit';
4
5
 
5
6
  describe('stompSource', () => {
6
7
  it('throws on missing url', () => {
@@ -61,3 +62,85 @@ describe('stompSource', () => {
61
62
  assert.strictEqual(typeof source.start, 'function', 'manualAck source missing start');
62
63
  });
63
64
  });
65
+
66
+ /**
67
+ * Captures the protocol headers using an inert channel.
68
+ *
69
+ * @param {object} options - Source options
70
+ * @returns {object} SUBSCRIBE headers
71
+ */
72
+ function subscription(options) {
73
+ const original = stompit.Channel;
74
+ let headers;
75
+ stompit.Channel = function Channel() {
76
+ return {
77
+ subscribe(value) { headers = value; },
78
+ close() { return undefined; }
79
+ };
80
+ };
81
+ try {
82
+ const source = stompSource('stomp://localhost:61613', '/queue/backlog', {
83
+ accept() { return undefined; }
84
+ }, options);
85
+ source.start();
86
+ source.stop();
87
+ return headers;
88
+ } finally {
89
+ stompit.Channel = original;
90
+ }
91
+ }
92
+
93
+ describe('STOMP delivery window', function() {
94
+ it('bounds the default backlog without changing acknowledgements', function() {
95
+ assert.deepStrictEqual(subscription({ manualAck: true, serial: false }), {
96
+ destination: '/queue/backlog',
97
+ ack: 'client-individual',
98
+ 'prefetch-count': '32'
99
+ });
100
+ });
101
+
102
+ it('sends a configured finite delivery window', function() {
103
+ const prefetch = Math.floor(Math.random() * 100) + 1;
104
+ assert.strictEqual(subscription({ prefetch })['prefetch-count'], String(prefetch));
105
+ });
106
+
107
+ for (const prefetch of [0, -1, 1.5, Infinity, NaN, '32']) {
108
+ it(`rejects invalid prefetch ${prefetch}`, function() {
109
+ assert.throws(function() {
110
+ stompSource('stomp://localhost:61613', '/queue/backlog', {
111
+ accept() { return undefined; }
112
+ }, { prefetch });
113
+ }, /prefetch must be a positive safe integer/u);
114
+ });
115
+ }
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
+ });