queen-mq 1.0.5 → 1.1.0
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 +62 -0
- package/client-v2/Queen.js +62 -2
- package/client-v2/admin/Admin.js +15 -1
- package/client-v2/buffer/BufferManager.js +198 -129
- package/client-v2/buffer/MessageBuffer.js +274 -54
- package/client-v2/buffer/sinks.js +89 -0
- package/client-v2/builders/QueueBuilder.js +117 -11
- package/client-v2/consumer/ConsumerManager.js +39 -4
- package/client-v2/ephemeral/Ephemeral.js +551 -0
- package/client-v2/index.js +12 -0
- package/client-v2/streams/Stream.js +3 -0
- package/client-v2/streams/runtime/Runner.js +18 -0
- package/client-v2/utils/conflation.js +118 -0
- package/client-v2/utils/defaults.js +23 -2
- package/package.json +3 -3
- package/test-v2/buffer-unit/buffer.test.js +404 -0
- package/test-v2/conflation-unit/conflationWire.test.js +398 -0
- package/test-v2/ephemeral-unit/_planServer.js +73 -0
- package/test-v2/ephemeral-unit/durableSinkPin.test.js +112 -0
- package/test-v2/ephemeral-unit/ephemeralBuffer.test.js +305 -0
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +398 -0
package/README.md
CHANGED
|
@@ -154,6 +154,53 @@ await queen.queue('events')
|
|
|
154
154
|
.consume(async (message) => { /* from timestamp */ })
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
### Conflation (Last-Value Delivery)
|
|
158
|
+
|
|
159
|
+
For command-style queues where one partition is one logical task key — "recompute
|
|
160
|
+
customer 42", "this entity is dirty" — only the newest pending message matters.
|
|
161
|
+
`.conflation(true)` makes a pop of a partition deliver exactly one message, the
|
|
162
|
+
newest visible one, and commit past everything it skipped:
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
// A backlog of 4 000 recompute requests across 12 entities becomes
|
|
166
|
+
// 12 handler calls, each with the freshest input.
|
|
167
|
+
await queen.queue('recompute')
|
|
168
|
+
.group('workers')
|
|
169
|
+
.conflation(true)
|
|
170
|
+
.partitions(64)
|
|
171
|
+
.consume(async (message) => {
|
|
172
|
+
await recompute(message.data.entityId)
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The guarantee: **after the last push to a partition, at least one handler run
|
|
177
|
+
starts after that push committed.** Nothing is deleted — conflation is a delivery
|
|
178
|
+
policy, not compaction; retention still governs what is stored, and a
|
|
179
|
+
non-conflating group on the same queue still sees every message.
|
|
180
|
+
|
|
181
|
+
Notes:
|
|
182
|
+
|
|
183
|
+
- It is a property of the **consumer group**, fixed when that group first
|
|
184
|
+
registers on the queue. A later consumer declaring the opposite does not flip
|
|
185
|
+
it — the stored value wins, that consumer keeps working, and the SDK warns
|
|
186
|
+
once per (queue, group).
|
|
187
|
+
- Skipping is per **partition**, so partitioning is the key: one partition = one
|
|
188
|
+
logical key is the contract this workload has to hold up.
|
|
189
|
+
- A conflating pop returns at most one message per partition, so **partitions**
|
|
190
|
+
size the round-trip, not `batch`. Left unset, the broker uses `.batch(N)` as
|
|
191
|
+
the partition cap; either way it is clamped to 64, so a conflating pop returns
|
|
192
|
+
at most 64 messages per round-trip whatever `batch` says.
|
|
193
|
+
- Refused with 400 by the broker without a `.group(...)`, and together with
|
|
194
|
+
`.autoAck(true)` (auto-ack commits at delivery, which would turn the guarantee
|
|
195
|
+
above into at-most-once).
|
|
196
|
+
- Requires broker **>= 1.1.0**. An older broker ignores the flag and would
|
|
197
|
+
quietly deliver the whole backlog, so the SDK raises
|
|
198
|
+
`conflation was requested but this broker did not apply it` on the first
|
|
199
|
+
response that does not echo it — before any message is processed.
|
|
200
|
+
- `admin.getQueueDepth(queue, group)` reports `effectivePending` (handler calls
|
|
201
|
+
still owed) next to `pending` (log positions still to retire). For a
|
|
202
|
+
conflating group `pending: 4000000, effectivePending: 12` is healthy.
|
|
203
|
+
|
|
157
204
|
---
|
|
158
205
|
|
|
159
206
|
## Connection Options
|
|
@@ -324,6 +371,21 @@ await queen.flushAllBuffers()
|
|
|
324
371
|
// Result: 10x-100x faster than individual pushes
|
|
325
372
|
```
|
|
326
373
|
|
|
374
|
+
The buffer is **bounded and lossless**, and both properties are why `push()` must
|
|
375
|
+
be awaited:
|
|
376
|
+
|
|
377
|
+
| Option | Default | Meaning |
|
|
378
|
+
| --- | --- | --- |
|
|
379
|
+
| `messageCount` | `100` | Flush once this many messages are waiting |
|
|
380
|
+
| `timeMillis` | `1000` | Or this long after the first message arrives |
|
|
381
|
+
| `maxSize` | `4 x messageCount` | Backpressure bound: past this many buffered messages, `push()` WAITS for the flusher instead of growing the heap. There is no unbounded setting |
|
|
382
|
+
| `retryDelayMillis` | `250` | Delay before retrying a batch whose POST failed. Failed batches go back to the front of the buffer, in order, and are retried — never dropped |
|
|
383
|
+
|
|
384
|
+
A producer that outruns the flush pipeline is therefore paced down to the drain
|
|
385
|
+
rate, and a broker outage shows up as slow pushes with bounded memory rather
|
|
386
|
+
than as messages that quietly disappeared. `close()` flushes with a 30 second
|
|
387
|
+
deadline and logs how many messages were left unsent if it expires.
|
|
388
|
+
|
|
327
389
|
### Dead Letter Queue
|
|
328
390
|
|
|
329
391
|
```javascript
|
package/client-v2/Queen.js
CHANGED
|
@@ -10,6 +10,7 @@ import { QueueBuilder } from './builders/QueueBuilder.js'
|
|
|
10
10
|
import { TransactionBuilder } from './builders/TransactionBuilder.js'
|
|
11
11
|
import { TimerBuilder } from './builders/TimerBuilder.js'
|
|
12
12
|
import { Kv } from './kv/Kv.js'
|
|
13
|
+
import { Ephemeral } from './ephemeral/Ephemeral.js'
|
|
13
14
|
import { StreamBuilder } from './stream/StreamBuilder.js'
|
|
14
15
|
import { StreamConsumer } from './stream/StreamConsumer.js'
|
|
15
16
|
import { Admin } from './admin/Admin.js'
|
|
@@ -17,6 +18,12 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
|
|
|
17
18
|
import { validateUrl, validateUrls } from './utils/validation.js'
|
|
18
19
|
import * as logger from './utils/logger.js'
|
|
19
20
|
|
|
21
|
+
// How long close() keeps retrying a push batch the broker will not take before
|
|
22
|
+
// it gives up, logs how many messages were never sent, and lets the process
|
|
23
|
+
// exit. Matches CLIENT_DEFAULTS.timeoutMillis and the usual 30s SIGTERM grace:
|
|
24
|
+
// long enough to ride out a broker restart, short enough that shutdown ends.
|
|
25
|
+
const CLOSE_FLUSH_DEADLINE_MILLIS = 30000
|
|
26
|
+
|
|
20
27
|
// Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
|
|
21
28
|
// one item per acknowledgment in request order:
|
|
22
29
|
// [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
|
|
@@ -60,6 +67,7 @@ export class Queen {
|
|
|
60
67
|
#shutdownHandlers = []
|
|
61
68
|
#admin = null
|
|
62
69
|
#kv = null
|
|
70
|
+
#ephemeral = null
|
|
63
71
|
|
|
64
72
|
constructor(config = {}) {
|
|
65
73
|
// Configure custom logger before anything else.
|
|
@@ -247,6 +255,54 @@ export class Queen {
|
|
|
247
255
|
return this.#kv
|
|
248
256
|
}
|
|
249
257
|
|
|
258
|
+
// ===========================
|
|
259
|
+
// Ephemeral API Entry Point
|
|
260
|
+
// ===========================
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* RAM-class queues: `/api/v1/ephemeral/*` (EPHEMERAL_QUEUES.md §1, §4).
|
|
264
|
+
*
|
|
265
|
+
* await queen.ephemeral.push('inbox:7', [{ hello: 'world' }])
|
|
266
|
+
* const { messages } = await queen.ephemeral.pop('inbox:7', { wait: true })
|
|
267
|
+
* await queen.ephemeral.ack('inbox:7', messages, { group: 'workers' })
|
|
268
|
+
*
|
|
269
|
+
* A different STORAGE CLASS, not a different API style. What changes:
|
|
270
|
+
*
|
|
271
|
+
* * CONTENTS SURVIVE NOTHING (§1.2) -- restart, crash, deploy, or the
|
|
272
|
+
* ownership move a membership change causes. Treat a failover like a
|
|
273
|
+
* Redis restart. A declared queue's OPTIONS are durable; it comes back
|
|
274
|
+
* configured and EMPTY.
|
|
275
|
+
* * a queue does not have to exist: the first push or pop that names one
|
|
276
|
+
* creates it, which is what makes thousands of short-lived req/reply
|
|
277
|
+
* inboxes cheap (§1.1).
|
|
278
|
+
* * delivery is at-least-once while the owning broker lives, at-most-once
|
|
279
|
+
* with `autoAck` (§1.3) -- NOT "at most once" as a class. Consumers still
|
|
280
|
+
* need idempotency.
|
|
281
|
+
* * consumption semantics are the pop's `group`, exactly as on durable
|
|
282
|
+
* queues (§1.5): same group competes, own group fans out, no group is
|
|
283
|
+
* queue mode. There is no queue-level mode to set.
|
|
284
|
+
* * there is no replay, no subscriptionMode, no DLQ, no transactions -- the
|
|
285
|
+
* verbs are absent because the concepts have no referent (§9).
|
|
286
|
+
*
|
|
287
|
+
* `push(..., {buffered})` shares the durable buffer machinery, so
|
|
288
|
+
* `queen.close()` drains it on the same deadline (§4.1).
|
|
289
|
+
*
|
|
290
|
+
* Requires broker/proxy >= 1.1; an older one 404s the whole family and every
|
|
291
|
+
* verb here maps that to `.code === EPHEMERAL_UNSUPPORTED`. Not to be
|
|
292
|
+
* confused with the OTHER 404: `depth` on a queue that does not exist raises
|
|
293
|
+
* `.code === EPHEMERAL_QUEUE_NOT_FOUND`, which is a missing queue and not a
|
|
294
|
+
* missing feature.
|
|
295
|
+
*
|
|
296
|
+
* Lazily initialized, singleton, like `admin` and `kv`.
|
|
297
|
+
* @returns {Ephemeral}
|
|
298
|
+
*/
|
|
299
|
+
get ephemeral() {
|
|
300
|
+
if (!this.#ephemeral) {
|
|
301
|
+
this.#ephemeral = new Ephemeral(this.#httpClient, this.#bufferManager)
|
|
302
|
+
}
|
|
303
|
+
return this.#ephemeral
|
|
304
|
+
}
|
|
305
|
+
|
|
250
306
|
// ===========================
|
|
251
307
|
// Timers API Entry Point
|
|
252
308
|
// ===========================
|
|
@@ -596,9 +652,13 @@ export class Queen {
|
|
|
596
652
|
async close() {
|
|
597
653
|
logger.log('Queen.close', 'Starting shutdown')
|
|
598
654
|
|
|
599
|
-
// Flush all buffers
|
|
655
|
+
// Flush all buffers, with a deadline. The flusher retries a failed batch
|
|
656
|
+
// forever rather than dropping it, which is right while the process is
|
|
657
|
+
// running and wrong on the way out: a SIGTERM grace period is finite, so
|
|
658
|
+
// shutdown stops retrying after CLOSE_FLUSH_DEADLINE_MILLIS and reports
|
|
659
|
+
// what is left instead of hanging until the runtime is killed.
|
|
600
660
|
try {
|
|
601
|
-
await this.#bufferManager.flushAllBuffers()
|
|
661
|
+
await this.#bufferManager.flushAllBuffers({ deadlineMillis: CLOSE_FLUSH_DEADLINE_MILLIS })
|
|
602
662
|
logger.log('Queen.close', 'All buffers flushed')
|
|
603
663
|
} catch (error) {
|
|
604
664
|
logger.error('Queen.close', { error: error.message, phase: 'buffer-flush' })
|
package/client-v2/admin/Admin.js
CHANGED
|
@@ -71,11 +71,25 @@ export class Admin {
|
|
|
71
71
|
/**
|
|
72
72
|
* Per-partition backlog for a queue — the cheap sibling of getQueue:
|
|
73
73
|
* watermark arithmetic only, no segments, no timestamps. Shape:
|
|
74
|
-
* {queue, group, pending,
|
|
74
|
+
* {queue, group, pending, partitionsPending, conflation, effectivePending,
|
|
75
|
+
* partitions: [{partition, pending}]}.
|
|
75
76
|
* Omitting group gives queue-level pending under the same worst-cursor
|
|
76
77
|
* precedence the dashboard publishes; a named group is that group's own
|
|
77
78
|
* backlog per partition. Requires broker >= 1.0.4 — an older broker
|
|
78
79
|
* answers 404 no_such_route, so fall back to getQueue there.
|
|
80
|
+
*
|
|
81
|
+
* The three fields added in 1.1.0 (PLAN_CONFLATION §2.5/§5.3):
|
|
82
|
+
* - `partitionsPending`: how many partitions owe work (pending > 0). Useful
|
|
83
|
+
* for every group; it is what queenctl used to compute client-side.
|
|
84
|
+
* - `conflation`: the group's stored last-value delivery policy.
|
|
85
|
+
* - `effectivePending`: WORK depth — handler invocations still owed. For a
|
|
86
|
+
* conflating group that is `partitionsPending` (one call per partition,
|
|
87
|
+
* newest message only); otherwise it equals `pending`.
|
|
88
|
+
*
|
|
89
|
+
* Read them together: for a conflating group `pending` is LOG depth (log
|
|
90
|
+
* positions still to retire), so `pending: 4000000, effectivePending: 12` is
|
|
91
|
+
* healthy — the same two numbers on a non-conflating group are an incident.
|
|
92
|
+
* Absent on brokers older than 1.1.0.
|
|
79
93
|
* @param {string} name - Queue name
|
|
80
94
|
* @param {string|null} [group] - Consumer group (optional)
|
|
81
95
|
* @returns {Promise<object>}
|
|
@@ -1,176 +1,232 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Buffer manager for client-side message buffering across queues
|
|
2
|
+
* Buffer manager for client-side message buffering across queues.
|
|
3
|
+
*
|
|
4
|
+
* One MessageBuffer per `queue/partition` address (the granularity the broker
|
|
5
|
+
* fuses writes on), and exactly ONE drain loop per buffer. The drain is the
|
|
6
|
+
* only thing that sends: it takes `messageCount`-sized batches off the front,
|
|
7
|
+
* POSTs them, and wakes producers parked on the buffer's maxSize bound after
|
|
8
|
+
* each batch that is definitively gone.
|
|
9
|
+
*
|
|
10
|
+
* A batch whose POST fails goes straight back to the front of the buffer, in
|
|
11
|
+
* order, and is retried after `retryDelayMillis` -- indefinitely, until it
|
|
12
|
+
* lands or the buffer is stopped. That is the half of the 2026-08-20 fix that
|
|
13
|
+
* removes loss on flush error; MessageBuffer's docs carry the other half
|
|
14
|
+
* (blocking backpressure) and the measurements behind both.
|
|
15
|
+
*
|
|
16
|
+
* Deadlines: an explicit flush (`flushBuffer`, `flushAllBuffers`) may pass
|
|
17
|
+
* `deadlineMillis` to bound how long it is willing to keep retrying, because
|
|
18
|
+
* "retry forever" is right for a background flusher and wrong for a shutdown
|
|
19
|
+
* path that has a SIGTERM grace period to respect. When the deadline expires
|
|
20
|
+
* the messages are still in the buffer -- the error says how many -- so the
|
|
21
|
+
* failure is loud rather than silent.
|
|
22
|
+
*
|
|
23
|
+
* WHERE a batch goes is the buffer's DESTINATION (buffer/sinks.js), not
|
|
24
|
+
* something this loop knows: durable pushes and ephemeral pushes are two routes
|
|
25
|
+
* with two body shapes and exactly one set of ordering, backpressure and retry
|
|
26
|
+
* semantics, so the drain is parametrized instead of copied. Addresses are
|
|
27
|
+
* namespaced per family (`eph:` prefix), so the two never share a buffer, a
|
|
28
|
+
* drain, or a retry queue. A buffer created without a destination drains to the
|
|
29
|
+
* durable push exactly as before.
|
|
3
30
|
*/
|
|
4
31
|
|
|
5
32
|
import { MessageBuffer } from './MessageBuffer.js'
|
|
6
|
-
import { BUFFER_DEFAULTS } from '../utils/defaults.js'
|
|
7
33
|
import * as logger from '../utils/logger.js'
|
|
8
34
|
|
|
9
35
|
export class BufferManager {
|
|
10
36
|
#httpClient
|
|
11
37
|
#buffers = new Map() // queueAddress -> MessageBuffer
|
|
12
|
-
#
|
|
38
|
+
#drains = new Map() // queueAddress -> { promise, ctl } for the in-flight drain
|
|
13
39
|
#flushCount = 0
|
|
40
|
+
#stopped = false
|
|
14
41
|
|
|
15
42
|
constructor(httpClient) {
|
|
16
43
|
this.#httpClient = httpClient
|
|
17
44
|
}
|
|
18
45
|
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Buffer one message, waiting for room if the buffer is at its bound.
|
|
48
|
+
*
|
|
49
|
+
* Returns a promise: the add path is where backpressure is applied, so
|
|
50
|
+
* callers MUST await it. PushBuilder does; anything that forgets would be
|
|
51
|
+
* back to the unbounded behaviour this replaced.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} queueAddress
|
|
54
|
+
* @param {object} formattedMessage
|
|
55
|
+
* @param {object} bufferOptions
|
|
56
|
+
* @param {{ signal?: AbortSignal, destination?: object }} [opts] - `destination`
|
|
57
|
+
* is `{ sink, queue, partition }` (buffer/sinks.js) and is read ONLY when
|
|
58
|
+
* this address's buffer is created: an address belongs to one queue of one
|
|
59
|
+
* storage class, so its route cannot change under an in-flight retry.
|
|
60
|
+
*/
|
|
61
|
+
async addMessage(queueAddress, formattedMessage, bufferOptions, { signal, destination = null } = {}) {
|
|
62
|
+
// A push after cleanup() would otherwise create a fresh buffer that nothing
|
|
63
|
+
// will ever flush -- messages accepted into a client that is already shut
|
|
64
|
+
// down, which is the same false success the bound exists to remove.
|
|
65
|
+
if (this.#stopped) {
|
|
66
|
+
throw new Error(`Queen client is closed: message not buffered for ${queueAddress}`)
|
|
67
|
+
}
|
|
21
68
|
|
|
22
69
|
if (!this.#buffers.has(queueAddress)) {
|
|
23
|
-
|
|
24
|
-
this
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
70
|
+
// The raw options go to the buffer, which fills in the defaults itself:
|
|
71
|
+
// maxSize is derived from the messageCount this caller asked for, and
|
|
72
|
+
// merging defaults here first would hide the difference between "not set"
|
|
73
|
+
// and "set to the default".
|
|
74
|
+
const created = new MessageBuffer(queueAddress, bufferOptions, (addr) => { this.#startDrain(addr) }, destination)
|
|
75
|
+
logger.log('BufferManager.createBuffer', { queueAddress, options: created.options, sink: created.destination.sink.name })
|
|
76
|
+
this.#buffers.set(queueAddress, created)
|
|
29
77
|
}
|
|
30
78
|
|
|
31
79
|
const buffer = this.#buffers.get(queueAddress)
|
|
32
|
-
buffer.add(formattedMessage)
|
|
80
|
+
await buffer.add(formattedMessage, { signal })
|
|
33
81
|
logger.log('BufferManager.addMessage', { queueAddress, messageCount: buffer.messageCount })
|
|
34
82
|
}
|
|
35
83
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Start the drain loop for an address, or join the one already running.
|
|
86
|
+
*
|
|
87
|
+
* Joining rather than starting a second loop is what keeps batches in order:
|
|
88
|
+
* two concurrent senders on the same partition would interleave their POSTs.
|
|
89
|
+
* Returns null when there is nothing to send.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} queueAddress
|
|
92
|
+
* @param {number|null} deadlineMillis - how long a caller is willing to keep
|
|
93
|
+
* retrying a failing batch; null (the default, and what background flushes
|
|
94
|
+
* use) means "until it lands or the buffer stops".
|
|
95
|
+
*/
|
|
96
|
+
#startDrain(queueAddress, deadlineMillis = null) {
|
|
97
|
+
const deadline = deadlineMillis === null || deadlineMillis === undefined
|
|
98
|
+
? Number.POSITIVE_INFINITY
|
|
99
|
+
: Date.now() + deadlineMillis
|
|
100
|
+
|
|
101
|
+
const running = this.#drains.get(queueAddress)
|
|
102
|
+
if (running) {
|
|
103
|
+
// A caller with a deadline joining a background drain tightens it: the
|
|
104
|
+
// shortest patience wins, otherwise a shutdown could be held open by a
|
|
105
|
+
// retry loop that was started with none.
|
|
106
|
+
running.ctl.deadline = Math.min(running.ctl.deadline, deadline)
|
|
107
|
+
return running.promise
|
|
41
108
|
}
|
|
42
109
|
|
|
43
|
-
logger.log('BufferManager.flushBuffer', { queueAddress, messageCount: buffer.messageCount })
|
|
44
|
-
buffer.setFlushing(true)
|
|
45
|
-
|
|
46
|
-
// Create a promise for this flush and track it
|
|
47
|
-
const flushPromise = (async () => {
|
|
48
|
-
try {
|
|
49
|
-
const messages = buffer.extractMessages()
|
|
50
|
-
|
|
51
|
-
logger.debug('BufferManager.flushBuffer', { queueAddress, extracted: messages.length })
|
|
52
|
-
|
|
53
|
-
if (messages.length === 0) return
|
|
54
|
-
|
|
55
|
-
// Send to server
|
|
56
|
-
const result = await this.#httpClient.post('/api/v1/push', { items: messages })
|
|
57
|
-
logger.debug('BufferManager.flushBuffer', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
58
|
-
|
|
59
|
-
this.#flushCount++
|
|
60
|
-
logger.log('BufferManager.flushBuffer', { queueAddress, status: 'success', messagesSent: messages.length })
|
|
61
|
-
|
|
62
|
-
// Remove empty buffer
|
|
63
|
-
this.#buffers.delete(queueAddress)
|
|
64
|
-
|
|
65
|
-
} catch (error) {
|
|
66
|
-
logger.error('BufferManager.flushBuffer', { queueAddress, error: error.message })
|
|
67
|
-
buffer.setFlushing(false)
|
|
68
|
-
throw error
|
|
69
|
-
} finally {
|
|
70
|
-
// Remove from pending flushes
|
|
71
|
-
this.#pendingFlushes.delete(flushPromise)
|
|
72
|
-
}
|
|
73
|
-
})()
|
|
74
|
-
|
|
75
|
-
// Track this flush
|
|
76
|
-
this.#pendingFlushes.add(flushPromise)
|
|
77
|
-
|
|
78
|
-
return flushPromise
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async #flushBufferBatch(queueAddress, batchSize) {
|
|
82
110
|
const buffer = this.#buffers.get(queueAddress)
|
|
83
|
-
if (!buffer || buffer.
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
logger.debug('BufferManager.flushBufferBatch', { queueAddress, extracted: messages.length })
|
|
95
|
-
|
|
96
|
-
if (messages.length === 0) return
|
|
97
|
-
|
|
98
|
-
// Send to server
|
|
99
|
-
const result = await this.#httpClient.post('/api/v1/push', { items: messages })
|
|
100
|
-
logger.debug('BufferManager.flushBufferBatch', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
101
|
-
|
|
102
|
-
this.#flushCount++
|
|
111
|
+
if (!buffer || !buffer.beginFlush()) return null
|
|
112
|
+
|
|
113
|
+
const ctl = { deadline }
|
|
114
|
+
const promise = this.#drain(queueAddress, buffer, ctl)
|
|
115
|
+
this.#drains.set(queueAddress, { promise, ctl })
|
|
116
|
+
// The count-threshold and timer triggers do not await this promise, so give
|
|
117
|
+
// it a handler of its own: a deadline tightened by a concurrent explicit
|
|
118
|
+
// flush would otherwise surface as an unhandled rejection.
|
|
119
|
+
promise.catch(() => {})
|
|
120
|
+
return promise
|
|
121
|
+
}
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
async #drain(queueAddress, buffer, ctl) {
|
|
124
|
+
const { messageCount, retryDelayMillis } = buffer.options
|
|
125
|
+
const { sink, queue, partition } = buffer.destination
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
while (buffer.messageCount > 0 && !buffer.isStopped) {
|
|
129
|
+
const batch = buffer.takeBatch(messageCount)
|
|
130
|
+
if (batch.length === 0) break
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const result = await this.#httpClient.post(sink.path, sink.format(queue, partition, batch))
|
|
134
|
+
logger.debug('BufferManager.drain', { queueAddress, sink: sink.name, sent: batch.length, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
135
|
+
|
|
136
|
+
this.#flushCount++
|
|
137
|
+
// Capacity freed. Woken here rather than at takeBatch, because a
|
|
138
|
+
// batch that fails to send goes straight back: waking on extraction
|
|
139
|
+
// would let producers refill against room that never freed.
|
|
140
|
+
buffer.wakeWaiters()
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// NOT dropped. The batch goes back at the front, in order, and this
|
|
143
|
+
// loop retries it. Before 2026-08-20 this branch logged and moved on,
|
|
144
|
+
// losing up to messageCount messages per failed POST.
|
|
145
|
+
buffer.restoreBatch(batch)
|
|
146
|
+
logger.error('BufferManager.drain', { queueAddress, error: error.message, requeued: batch.length })
|
|
147
|
+
|
|
148
|
+
const remaining = ctl.deadline - Date.now()
|
|
149
|
+
if (remaining <= 0) {
|
|
150
|
+
error.queenUnflushedCount = buffer.messageCount
|
|
151
|
+
error.message = `${error.message} (${buffer.messageCount} message(s) still buffered for ${queueAddress}, not sent)`
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await buffer.sleepUnlessStopped(Math.min(retryDelayMillis, remaining))
|
|
109
156
|
}
|
|
110
|
-
|
|
111
|
-
} catch (error) {
|
|
112
|
-
logger.error('BufferManager.flushBufferBatch', { queueAddress, error: error.message })
|
|
113
|
-
buffer.setFlushing(false)
|
|
114
|
-
throw error
|
|
115
|
-
} finally {
|
|
116
|
-
// Remove from pending flushes
|
|
117
|
-
this.#pendingFlushes.delete(flushPromise)
|
|
118
157
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
158
|
+
} finally {
|
|
159
|
+
buffer.endFlush()
|
|
160
|
+
this.#drains.delete(queueAddress)
|
|
161
|
+
// Drop the entry only when nothing can still be pointed at it: a parked
|
|
162
|
+
// add holds this exact object, and deleting it here would leave that add
|
|
163
|
+
// appending into an orphan no drain would ever visit.
|
|
164
|
+
if (buffer.messageCount === 0 && !buffer.hasParkedAdds && !buffer.isStopped) {
|
|
165
|
+
this.#buffers.delete(queueAddress)
|
|
166
|
+
}
|
|
167
|
+
buffer.wakeWaiters()
|
|
168
|
+
}
|
|
125
169
|
}
|
|
126
170
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Send everything buffered for one address.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} queueAddress
|
|
175
|
+
* @param {{ deadlineMillis?: number }} [opts] - stop retrying a failing batch
|
|
176
|
+
* after this long and throw (the messages stay in the buffer). Omit to
|
|
177
|
+
* retry until the batch lands.
|
|
178
|
+
*/
|
|
179
|
+
async flushBuffer(queueAddress, { deadlineMillis = null } = {}) {
|
|
180
|
+
logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#drains.size })
|
|
181
|
+
|
|
130
182
|
const buffer = this.#buffers.get(queueAddress)
|
|
131
183
|
if (!buffer) {
|
|
132
184
|
logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'not-found' })
|
|
133
|
-
await this.#
|
|
185
|
+
await this.#waitForDrains()
|
|
134
186
|
return
|
|
135
187
|
}
|
|
136
188
|
|
|
137
|
-
// Cancel timer to prevent time-based flush
|
|
189
|
+
// Cancel the timer to prevent a time-based flush racing this one.
|
|
138
190
|
buffer.cancelTimer()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// Flush all messages in batches
|
|
144
|
-
while (buffer.messageCount > 0) {
|
|
145
|
-
logger.debug('BufferManager.flushBuffer', { queueAddress, batchSize, remaining: buffer.messageCount })
|
|
146
|
-
await this.#flushBufferBatch(queueAddress, batchSize)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Wait for all pending flushes to complete
|
|
150
|
-
await this.#waitForPendingFlushes()
|
|
151
|
-
|
|
191
|
+
|
|
192
|
+
const drain = this.#startDrain(queueAddress, deadlineMillis)
|
|
193
|
+
if (drain) await drain
|
|
194
|
+
|
|
152
195
|
logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'completed' })
|
|
153
196
|
}
|
|
154
197
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Send everything buffered, for every address.
|
|
200
|
+
*
|
|
201
|
+
* Drains run concurrently across addresses (they are independent buffers, and
|
|
202
|
+
* a shutdown should not pay for them one at a time) and every one is awaited
|
|
203
|
+
* before the first error is rethrown: an unreachable queue must not strand
|
|
204
|
+
* the others' messages.
|
|
205
|
+
*/
|
|
206
|
+
async flushAllBuffers({ deadlineMillis = null } = {}) {
|
|
207
|
+
const queueAddresses = new Set([...this.#buffers.keys(), ...this.#drains.keys()])
|
|
208
|
+
logger.log('BufferManager.flushAllBuffers', { bufferCount: queueAddresses.size, pendingFlushes: this.#drains.size })
|
|
209
|
+
|
|
210
|
+
const drains = []
|
|
161
211
|
for (const queueAddress of queueAddresses) {
|
|
162
|
-
|
|
212
|
+
const buffer = this.#buffers.get(queueAddress)
|
|
213
|
+
if (buffer) buffer.cancelTimer()
|
|
214
|
+
const drain = this.#startDrain(queueAddress, deadlineMillis)
|
|
215
|
+
if (drain) drains.push(drain)
|
|
163
216
|
}
|
|
164
|
-
|
|
165
|
-
|
|
217
|
+
|
|
218
|
+
const outcomes = await Promise.allSettled(drains)
|
|
219
|
+
const failure = outcomes.find(outcome => outcome.status === 'rejected')
|
|
220
|
+
|
|
221
|
+
logger.log('BufferManager.flushAllBuffers', { status: failure ? 'failed' : 'completed' })
|
|
222
|
+
if (failure) throw failure.reason
|
|
166
223
|
}
|
|
167
224
|
|
|
168
|
-
async #
|
|
169
|
-
if (this.#
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
logger.debug('BufferManager.waitForPendingFlushes', { status: 'completed' })
|
|
225
|
+
async #waitForDrains() {
|
|
226
|
+
if (this.#drains.size === 0) return
|
|
227
|
+
logger.debug('BufferManager.waitForDrains', { count: this.#drains.size })
|
|
228
|
+
await Promise.allSettled([...this.#drains.values()].map(entry => entry.promise))
|
|
229
|
+
logger.debug('BufferManager.waitForDrains', { status: 'completed' })
|
|
174
230
|
}
|
|
175
231
|
|
|
176
232
|
getStats() {
|
|
@@ -189,12 +245,25 @@ export class BufferManager {
|
|
|
189
245
|
oldestBufferAge,
|
|
190
246
|
flushesPerformed: this.#flushCount
|
|
191
247
|
}
|
|
192
|
-
|
|
248
|
+
|
|
193
249
|
logger.log('BufferManager.getStats', stats)
|
|
194
250
|
return stats
|
|
195
251
|
}
|
|
196
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Stop every buffer and discard what is left.
|
|
255
|
+
*
|
|
256
|
+
* Stopping wakes parked adds (they reject: their message was never buffered)
|
|
257
|
+
* and ends any retry loop, so this also unhangs a drain that was waiting out
|
|
258
|
+
* a broker outage. Anything still buffered here is lost -- which is why
|
|
259
|
+
* Queen.close() flushes with a deadline first and logs what remains.
|
|
260
|
+
*/
|
|
197
261
|
cleanup() {
|
|
262
|
+
this.#stopped = true
|
|
263
|
+
const unflushed = this.getStats().totalBufferedMessages
|
|
264
|
+
if (unflushed > 0) {
|
|
265
|
+
logger.error('BufferManager.cleanup', { unflushedMessages: unflushed, status: 'discarded' })
|
|
266
|
+
}
|
|
198
267
|
logger.log('BufferManager.cleanup', { bufferCount: this.#buffers.size })
|
|
199
268
|
for (const buffer of this.#buffers.values()) {
|
|
200
269
|
buffer.cleanup()
|