queen-mq 1.0.6 → 1.2.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 +110 -4
- package/client-v2/Queen.js +68 -0
- package/client-v2/admin/Admin.js +15 -1
- package/client-v2/buffer/BufferManager.js +18 -6
- package/client-v2/buffer/MessageBuffer.js +17 -1
- package/client-v2/buffer/sinks.js +89 -0
- package/client-v2/builders/QueueBuilder.js +175 -16
- package/client-v2/consumer/ConsumerManager.js +79 -12
- 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 +26 -3
- package/client-v2/utils/autopilot.js +168 -0
- package/client-v2/utils/conflation.js +118 -0
- package/client-v2/utils/defaults.js +16 -3
- package/package.json +3 -3
- package/test-v2/autopilot-unit/autopilotWire.test.js +410 -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
|
|
@@ -286,12 +333,64 @@ per-tenant work queues, per-device telemetry). Reduces network round-trips
|
|
|
286
333
|
from O(P) to O(P / N) while preserving per-partition FIFO ordering.
|
|
287
334
|
|
|
288
335
|
**When not to use:** few partitions, or each one busy enough to fill
|
|
289
|
-
`batch(B)` on its own.
|
|
290
|
-
|
|
336
|
+
`batch(B)` on its own. Leaving `.partitions()` unset hands the sweep width to
|
|
337
|
+
the broker (see Pop Autopilot below); `.partitions(1)` pins the legacy
|
|
338
|
+
single-partition behaviour.
|
|
291
339
|
|
|
292
340
|
`.partitions(N)` only applies to **wildcard** pops; specifying
|
|
293
341
|
`.partition('name')` ignores the cap.
|
|
294
342
|
|
|
343
|
+
### Pop Autopilot (Let the Broker Size the Pop)
|
|
344
|
+
|
|
345
|
+
Since 1.2, `batch` and `partitions` that you do **not** set are chosen by the
|
|
346
|
+
broker, per pop, from state the client cannot see: how many partitions of the
|
|
347
|
+
group are ready, how old their oldest ready message is, how fast messages are
|
|
348
|
+
arriving. The knobs you *do* set are never touched.
|
|
349
|
+
|
|
350
|
+
```javascript
|
|
351
|
+
// Both knobs are the broker's: it picks the sweep width and the budget.
|
|
352
|
+
await queen.queue('events').group('workers')
|
|
353
|
+
.consume(async (msgs) => { /* ... */ })
|
|
354
|
+
|
|
355
|
+
// One knob pinned, one delegated: this consumer stays on one partition
|
|
356
|
+
// forever, and the broker sizes the batch for it.
|
|
357
|
+
await queen.queue('events').group('workers').partitions(1)
|
|
358
|
+
.consume(async (msgs) => { /* ... */ })
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The request carries `autopilot=true` and simply omits the delegated knobs.
|
|
362
|
+
Setting both leaves nothing to decide, so nothing changes on the wire at all.
|
|
363
|
+
|
|
364
|
+
**Two ways to switch it off**, both restoring the previous client-side
|
|
365
|
+
defaults (batch 1, partitions 1) byte for byte:
|
|
366
|
+
|
|
367
|
+
```javascript
|
|
368
|
+
await queen.queue('events').autopilot(false).consume(handler)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
QUEEN_SDK_POP_AUTOPILOT=off # whole process, read once at client creation
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**What the broker chose** rides back on the response and is there for the
|
|
376
|
+
reading, along with an optional pacing hint the consume loop honours in place
|
|
377
|
+
of its own delay between empty polls:
|
|
378
|
+
|
|
379
|
+
```javascript
|
|
380
|
+
const { messages, autopilot } = await queen.queue('events').group('workers').popResult()
|
|
381
|
+
if (autopilot) {
|
|
382
|
+
console.log(`${autopilot.partitions} partitions, batch ${autopilot.batch}, ` +
|
|
383
|
+
`poll again in ${autopilot.waitMillis}ms`)
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Requires broker >= 1.2.** An older broker ignores the parameter, so the
|
|
388
|
+
omitted knobs take *its* defaults (batch 200, partitions 1) instead of the old
|
|
389
|
+
client-side ones. That is a sizing difference and nothing else — no message is
|
|
390
|
+
lost, reordered or duplicated — so unlike conflation it degrades silently and
|
|
391
|
+
on purpose. Pin the values explicitly, or turn autopilot off, if you need the
|
|
392
|
+
old numbers against an old broker.
|
|
393
|
+
|
|
295
394
|
### Transactions (Atomic Operations)
|
|
296
395
|
|
|
297
396
|
```javascript
|
|
@@ -644,10 +743,11 @@ await queen.queue('q').buffer({ messageCount: 100, timeMillis: 1000 }).push([...
|
|
|
644
743
|
### Pop
|
|
645
744
|
|
|
646
745
|
```javascript
|
|
647
|
-
const msgs = await queen.queue('q').pop()
|
|
746
|
+
const msgs = await queen.queue('q').pop() // broker-sized (see Pop Autopilot)
|
|
648
747
|
const msgs = await queen.queue('q').batch(10).pop()
|
|
649
748
|
const msgs = await queen.queue('q').batch(10).wait(true).pop()
|
|
650
749
|
const msgs = await queen.queue('q').batch(200).partitions(50).pop() // multi-partition pop
|
|
750
|
+
const { messages, autopilot } = await queen.queue('q').popResult() // + what the broker chose
|
|
651
751
|
```
|
|
652
752
|
|
|
653
753
|
### Consume
|
|
@@ -781,7 +881,8 @@ await queen.close() // Flush buffers and close connections
|
|
|
781
881
|
```javascript
|
|
782
882
|
{
|
|
783
883
|
concurrency: 1,
|
|
784
|
-
batch: 1,
|
|
884
|
+
batch: 1, // autopilot OFF only -- unset means the broker sizes it
|
|
885
|
+
partitions: 1, // autopilot OFF only -- unset means the broker sizes it
|
|
785
886
|
autoAck: true,
|
|
786
887
|
wait: true, // Long polling
|
|
787
888
|
timeoutMillis: 30000,
|
|
@@ -790,6 +891,11 @@ await queen.close() // Flush buffers and close connections
|
|
|
790
891
|
}
|
|
791
892
|
```
|
|
792
893
|
|
|
894
|
+
`batch` and `partitions` are the **autopilot-off** defaults: with autopilot on
|
|
895
|
+
(the default) a knob you never set is not defaulted at all, it is delegated to
|
|
896
|
+
the broker. These values are what comes back with `.autopilot(false)` or
|
|
897
|
+
`QUEEN_SDK_POP_AUTOPILOT=off`.
|
|
898
|
+
|
|
793
899
|
---
|
|
794
900
|
|
|
795
901
|
## Logging
|
package/client-v2/Queen.js
CHANGED
|
@@ -10,9 +10,11 @@ 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
|
+
import { popAutopilotDisabledByEnv } from './utils/autopilot.js'
|
|
16
18
|
import { CLIENT_DEFAULTS } from './utils/defaults.js'
|
|
17
19
|
import { validateUrl, validateUrls } from './utils/validation.js'
|
|
18
20
|
import * as logger from './utils/logger.js'
|
|
@@ -66,6 +68,12 @@ export class Queen {
|
|
|
66
68
|
#shutdownHandlers = []
|
|
67
69
|
#admin = null
|
|
68
70
|
#kv = null
|
|
71
|
+
#ephemeral = null
|
|
72
|
+
// Process-wide kill switch for pop autopilot, read from
|
|
73
|
+
// QUEEN_SDK_POP_AUTOPILOT once here rather than on every pop: it is a
|
|
74
|
+
// deployment-level rollback, and re-reading it per request would let a
|
|
75
|
+
// running process change wire shape halfway through.
|
|
76
|
+
#autopilotOff = false
|
|
69
77
|
|
|
70
78
|
constructor(config = {}) {
|
|
71
79
|
// Configure custom logger before anything else.
|
|
@@ -81,6 +89,9 @@ export class Queen {
|
|
|
81
89
|
// Normalize config
|
|
82
90
|
this.#config = this.#normalizeConfig(config)
|
|
83
91
|
|
|
92
|
+
// Pop autopilot: on unless the environment rolls it back (utils/autopilot.js).
|
|
93
|
+
this.#autopilotOff = popAutopilotDisabledByEnv()
|
|
94
|
+
|
|
84
95
|
// Create HTTP client
|
|
85
96
|
this.#httpClient = this.#createHttpClient()
|
|
86
97
|
|
|
@@ -95,6 +106,15 @@ export class Queen {
|
|
|
95
106
|
logger.log('Queen.constructor', { status: 'initialized', urls: this.#config.urls.length, handleSignals: this.#config.handleSignals })
|
|
96
107
|
}
|
|
97
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Whether pop autopilot is off for this client because the environment asked
|
|
111
|
+
* (QUEEN_SDK_POP_AUTOPILOT). Read by the builders; a per-call
|
|
112
|
+
* `.autopilot(...)` still outranks it.
|
|
113
|
+
*/
|
|
114
|
+
get autopilotOff() {
|
|
115
|
+
return this.#autopilotOff
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
#normalizeConfig(config) {
|
|
99
119
|
// Handle different input formats
|
|
100
120
|
if (typeof config === 'string') {
|
|
@@ -253,6 +273,54 @@ export class Queen {
|
|
|
253
273
|
return this.#kv
|
|
254
274
|
}
|
|
255
275
|
|
|
276
|
+
// ===========================
|
|
277
|
+
// Ephemeral API Entry Point
|
|
278
|
+
// ===========================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* RAM-class queues: `/api/v1/ephemeral/*` (EPHEMERAL_QUEUES.md §1, §4).
|
|
282
|
+
*
|
|
283
|
+
* await queen.ephemeral.push('inbox:7', [{ hello: 'world' }])
|
|
284
|
+
* const { messages } = await queen.ephemeral.pop('inbox:7', { wait: true })
|
|
285
|
+
* await queen.ephemeral.ack('inbox:7', messages, { group: 'workers' })
|
|
286
|
+
*
|
|
287
|
+
* A different STORAGE CLASS, not a different API style. What changes:
|
|
288
|
+
*
|
|
289
|
+
* * CONTENTS SURVIVE NOTHING (§1.2) -- restart, crash, deploy, or the
|
|
290
|
+
* ownership move a membership change causes. Treat a failover like a
|
|
291
|
+
* Redis restart. A declared queue's OPTIONS are durable; it comes back
|
|
292
|
+
* configured and EMPTY.
|
|
293
|
+
* * a queue does not have to exist: the first push or pop that names one
|
|
294
|
+
* creates it, which is what makes thousands of short-lived req/reply
|
|
295
|
+
* inboxes cheap (§1.1).
|
|
296
|
+
* * delivery is at-least-once while the owning broker lives, at-most-once
|
|
297
|
+
* with `autoAck` (§1.3) -- NOT "at most once" as a class. Consumers still
|
|
298
|
+
* need idempotency.
|
|
299
|
+
* * consumption semantics are the pop's `group`, exactly as on durable
|
|
300
|
+
* queues (§1.5): same group competes, own group fans out, no group is
|
|
301
|
+
* queue mode. There is no queue-level mode to set.
|
|
302
|
+
* * there is no replay, no subscriptionMode, no DLQ, no transactions -- the
|
|
303
|
+
* verbs are absent because the concepts have no referent (§9).
|
|
304
|
+
*
|
|
305
|
+
* `push(..., {buffered})` shares the durable buffer machinery, so
|
|
306
|
+
* `queen.close()` drains it on the same deadline (§4.1).
|
|
307
|
+
*
|
|
308
|
+
* Requires broker/proxy >= 1.1; an older one 404s the whole family and every
|
|
309
|
+
* verb here maps that to `.code === EPHEMERAL_UNSUPPORTED`. Not to be
|
|
310
|
+
* confused with the OTHER 404: `depth` on a queue that does not exist raises
|
|
311
|
+
* `.code === EPHEMERAL_QUEUE_NOT_FOUND`, which is a missing queue and not a
|
|
312
|
+
* missing feature.
|
|
313
|
+
*
|
|
314
|
+
* Lazily initialized, singleton, like `admin` and `kv`.
|
|
315
|
+
* @returns {Ephemeral}
|
|
316
|
+
*/
|
|
317
|
+
get ephemeral() {
|
|
318
|
+
if (!this.#ephemeral) {
|
|
319
|
+
this.#ephemeral = new Ephemeral(this.#httpClient, this.#bufferManager)
|
|
320
|
+
}
|
|
321
|
+
return this.#ephemeral
|
|
322
|
+
}
|
|
323
|
+
|
|
256
324
|
// ===========================
|
|
257
325
|
// Timers API Entry Point
|
|
258
326
|
// ===========================
|
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>}
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* path that has a SIGTERM grace period to respect. When the deadline expires
|
|
20
20
|
* the messages are still in the buffer -- the error says how many -- so the
|
|
21
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.
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import { MessageBuffer } from './MessageBuffer.js'
|
|
@@ -45,9 +53,12 @@ export class BufferManager {
|
|
|
45
53
|
* @param {string} queueAddress
|
|
46
54
|
* @param {object} formattedMessage
|
|
47
55
|
* @param {object} bufferOptions
|
|
48
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
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.
|
|
49
60
|
*/
|
|
50
|
-
async addMessage(queueAddress, formattedMessage, bufferOptions, { signal } = {}) {
|
|
61
|
+
async addMessage(queueAddress, formattedMessage, bufferOptions, { signal, destination = null } = {}) {
|
|
51
62
|
// A push after cleanup() would otherwise create a fresh buffer that nothing
|
|
52
63
|
// will ever flush -- messages accepted into a client that is already shut
|
|
53
64
|
// down, which is the same false success the bound exists to remove.
|
|
@@ -60,8 +71,8 @@ export class BufferManager {
|
|
|
60
71
|
// maxSize is derived from the messageCount this caller asked for, and
|
|
61
72
|
// merging defaults here first would hide the difference between "not set"
|
|
62
73
|
// and "set to the default".
|
|
63
|
-
const created = new MessageBuffer(queueAddress, bufferOptions, (addr) => { this.#startDrain(addr) })
|
|
64
|
-
logger.log('BufferManager.createBuffer', { queueAddress, options: created.options })
|
|
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 })
|
|
65
76
|
this.#buffers.set(queueAddress, created)
|
|
66
77
|
}
|
|
67
78
|
|
|
@@ -111,6 +122,7 @@ export class BufferManager {
|
|
|
111
122
|
|
|
112
123
|
async #drain(queueAddress, buffer, ctl) {
|
|
113
124
|
const { messageCount, retryDelayMillis } = buffer.options
|
|
125
|
+
const { sink, queue, partition } = buffer.destination
|
|
114
126
|
|
|
115
127
|
try {
|
|
116
128
|
while (buffer.messageCount > 0 && !buffer.isStopped) {
|
|
@@ -118,8 +130,8 @@ export class BufferManager {
|
|
|
118
130
|
if (batch.length === 0) break
|
|
119
131
|
|
|
120
132
|
try {
|
|
121
|
-
const result = await this.#httpClient.post(
|
|
122
|
-
logger.debug('BufferManager.drain', { queueAddress, sent: batch.length, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
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' })
|
|
123
135
|
|
|
124
136
|
this.#flushCount++
|
|
125
137
|
// Capacity freed. Woken here rather than at takeBatch, because a
|
|
@@ -34,14 +34,24 @@
|
|
|
34
34
|
* Buffered messages still live only in this process's memory. A crash, or a
|
|
35
35
|
* `process.exit()` that skips `close()`, loses them -- buffering belongs on
|
|
36
36
|
* telemetry-shaped traffic, not on anything that must not be lost.
|
|
37
|
+
*
|
|
38
|
+
* A buffer also carries its DESTINATION (buffer/sinks.js): the route its
|
|
39
|
+
* batches are posted to and the shape they are posted in. It is fixed at
|
|
40
|
+
* creation and never changes, because it is a property of the address -- one
|
|
41
|
+
* address is one queue of one storage class -- and because a drain that could
|
|
42
|
+
* change route mid-retry would post a re-queued batch somewhere its earlier
|
|
43
|
+
* attempt did not go. Absent, it is the durable push, which is what every
|
|
44
|
+
* caller that predates ephemeral queues gets without knowing sinks exist.
|
|
37
45
|
*/
|
|
38
46
|
|
|
39
47
|
import { BUFFER_DEFAULTS } from '../utils/defaults.js'
|
|
48
|
+
import { DURABLE_DESTINATION } from './sinks.js'
|
|
40
49
|
|
|
41
50
|
export class MessageBuffer {
|
|
42
51
|
#queueAddress
|
|
43
52
|
#messages = []
|
|
44
53
|
#options
|
|
54
|
+
#destination
|
|
45
55
|
#flushCallback
|
|
46
56
|
#timer = null
|
|
47
57
|
#firstMessageTime = null
|
|
@@ -58,10 +68,11 @@ export class MessageBuffer {
|
|
|
58
68
|
#parked = 0
|
|
59
69
|
#stopWaiters = []
|
|
60
70
|
|
|
61
|
-
constructor(queueAddress, options, flushCallback) {
|
|
71
|
+
constructor(queueAddress, options, flushCallback, destination = null) {
|
|
62
72
|
this.#queueAddress = queueAddress
|
|
63
73
|
this.#options = MessageBuffer.normalizeOptions(options)
|
|
64
74
|
this.#flushCallback = flushCallback
|
|
75
|
+
this.#destination = destination || DURABLE_DESTINATION
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
/**
|
|
@@ -298,6 +309,11 @@ export class MessageBuffer {
|
|
|
298
309
|
return this.#options
|
|
299
310
|
}
|
|
300
311
|
|
|
312
|
+
/** `{ sink, queue, partition }` -- where this buffer's batches are posted. */
|
|
313
|
+
get destination() {
|
|
314
|
+
return this.#destination
|
|
315
|
+
}
|
|
316
|
+
|
|
301
317
|
get isFlushing() {
|
|
302
318
|
return this.#flushing
|
|
303
319
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drain sinks: WHERE a buffered batch goes, and in WHAT shape.
|
|
3
|
+
*
|
|
4
|
+
* The buffer machinery -- blocking backpressure at `maxSize`, one drain loop
|
|
5
|
+
* per address, a failed batch put back at the FRONT and retried until it lands
|
|
6
|
+
* or a flush deadline expires -- is about ordering, occupancy and loss. None of
|
|
7
|
+
* that is durable-specific, and none of it is worth writing twice. So the drain
|
|
8
|
+
* takes a SINK instead of a hardcoded POST:
|
|
9
|
+
*
|
|
10
|
+
* { path, format(queue, partition, batch) -> body }
|
|
11
|
+
*
|
|
12
|
+
* `format` receives the queue and partition because the two storage classes
|
|
13
|
+
* disagree about where that identity lives on the wire, and that disagreement
|
|
14
|
+
* is the entire reason this parameter exists:
|
|
15
|
+
*
|
|
16
|
+
* * the DURABLE push wire repeats `{queue, partition}` on EVERY item, so the
|
|
17
|
+
* envelope is just `{items}` and the sink ignores both arguments;
|
|
18
|
+
* * the EPHEMERAL push wire hoists them to the envelope --
|
|
19
|
+
* `{queue, partition?, messages:[{payload}...]}` -- so the batch elements
|
|
20
|
+
* carry nothing but their payload.
|
|
21
|
+
*
|
|
22
|
+
* DURABLE_SINK IS TODAY'S REQUEST, BYTE FOR BYTE. It is the default for a
|
|
23
|
+
* buffer created without a destination, which is every caller that existed
|
|
24
|
+
* before ephemeral queues did, and test-v2/ephemeral-unit/durableSinkPin.test.js
|
|
25
|
+
* exists for no other reason than to fail if that ever stops being true.
|
|
26
|
+
*
|
|
27
|
+
* ADDRESSES ARE NAMESPACED. A buffer address is the key of the one-buffer-one-
|
|
28
|
+
* drain map, so an ephemeral `orders` and a durable `orders` must not hash to
|
|
29
|
+
* the same entry -- they are unrelated objects (EPHEMERAL_QUEUES.md §10 Q8) and
|
|
30
|
+
* a shared buffer would post one family's messages to the other family's route.
|
|
31
|
+
* The `eph:` prefix is the same namespacing the broker applies to its own queue
|
|
32
|
+
* keys (§3.2), for the same reason.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The durable push wire: identity per item, envelope carries only the batch. */
|
|
36
|
+
export const DURABLE_SINK = {
|
|
37
|
+
name: 'durable',
|
|
38
|
+
path: '/api/v1/push',
|
|
39
|
+
format(_queue, _partition, batch) {
|
|
40
|
+
return { items: batch }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The ephemeral push wire (EPHEMERAL_QUEUES.md §3.1): identity on the envelope. */
|
|
45
|
+
export const EPHEMERAL_SINK = {
|
|
46
|
+
name: 'ephemeral',
|
|
47
|
+
path: '/api/v1/ephemeral/push',
|
|
48
|
+
format(queue, partition, batch) {
|
|
49
|
+
const body = { queue }
|
|
50
|
+
// Omitted, never defaulted client-side: which partition an ephemeral push
|
|
51
|
+
// without one lands on is the broker's rule, and inventing a 'Default' here
|
|
52
|
+
// would take that decision away from it in a way the caller never asked for.
|
|
53
|
+
if (partition !== null && partition !== undefined) body.partition = partition
|
|
54
|
+
body.messages = batch
|
|
55
|
+
return body
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a buffer drains into: the sink, plus the identity that sink formats for.
|
|
61
|
+
* The default is the durable push, so a buffer created without one behaves
|
|
62
|
+
* exactly as buffers did before sinks existed.
|
|
63
|
+
*/
|
|
64
|
+
export const DURABLE_DESTINATION = { sink: DURABLE_SINK, queue: null, partition: null }
|
|
65
|
+
|
|
66
|
+
/** The ephemeral counterpart, bound to one (queue, partition). */
|
|
67
|
+
export function ephemeralDestination(queue, partition = null) {
|
|
68
|
+
return { sink: EPHEMERAL_SINK, queue, partition }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The durable buffer address, unchanged: `queue/partition`. Kept here next to
|
|
73
|
+
* its ephemeral sibling so the two can be compared at a glance.
|
|
74
|
+
*/
|
|
75
|
+
export function durableAddress(queue, partition) {
|
|
76
|
+
return `${queue}/${partition}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The ephemeral buffer address: `eph:queue/partition`, or `eph:queue` when the
|
|
81
|
+
* caller named no partition (which is a different destination from any named
|
|
82
|
+
* one, because the broker picks, and a buffer must not merge the two).
|
|
83
|
+
*
|
|
84
|
+
* Same ambiguity as the durable address -- a queue named `a/b` collides with
|
|
85
|
+
* (`a`, `b`) -- inherited deliberately rather than fixed on one side only.
|
|
86
|
+
*/
|
|
87
|
+
export function ephemeralAddress(queue, partition = null) {
|
|
88
|
+
return partition === null || partition === undefined ? `eph:${queue}` : `eph:${queue}/${partition}`
|
|
89
|
+
}
|