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 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. Default is `partitions(1)` which preserves the
290
- legacy single-partition behaviour.
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
@@ -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
  // ===========================
@@ -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, partitions: [{partition, 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('/api/v1/push', { items: batch })
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
+ }