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.
@@ -0,0 +1,551 @@
1
+ /**
2
+ * The ephemeral surface (EPHEMERAL_QUEUES.md §1, §3.1, §4).
3
+ *
4
+ * Eight verbs over one route family, `/api/v1/ephemeral/*`: configure, reset,
5
+ * delete, push, pop, ack, queues, depth. Flat functions, not a builder chain --
6
+ * the durable `queue(name).partition(p).push(...)` fluency exists because a
7
+ * durable queue has a dozen configured properties that read well as a sentence;
8
+ * an ephemeral queue has a ring in a broker's RAM and a handful of bounds, and
9
+ * a chain would only hide how few moving parts there are.
10
+ *
11
+ * WHAT THIS CLASS IS ABOUT, BEFORE ANY SIGNATURE: contents survive NOTHING
12
+ * (§1.2). Not a restart, not a crash, not a deploy, not the ownership move that
13
+ * a membership change causes. Treat a failover like a Redis restart. Declared
14
+ * CONFIGURATION is durable -- it lives in PG and comes back after a restart, as
15
+ * configured and EMPTY. There is no replay, no history, no subscriptionMode and
16
+ * no DLQ, because none of those concepts has a referent when there is no
17
+ * history to have.
18
+ *
19
+ * DELIVERY IS NOT "AT MOST ONCE" (§1.3), and the docs must not say it is. The
20
+ * class picks what can be LOST; the ack mode picks the guarantee. `autoAck`
21
+ * advances the cursor at delivery and is at-most-once. The default -- explicit
22
+ * ack -- is at-least-once for as long as the owning broker incarnation lives:
23
+ * an unacked message redelivers when its lease expires, with `attempts`
24
+ * incremented, until `retryLimit`, after which it is DROPPED and counted (no
25
+ * DLQ, §9). Consumers still need idempotency, exactly as on durable queues.
26
+ *
27
+ * CONSUMPTION SEMANTICS COME FROM THE GROUP, EXACTLY AS ON THE DURABLE ENGINE
28
+ * (§1.5). There is no queue-level mode to choose:
29
+ *
30
+ * pop(q, { group: 'workers' }) // competing consumers: one cursor
31
+ * pop(q, { group: 'tail-a' }) // fan-out: this subscriber's own cursor
32
+ * pop(q) // groupless queue mode, as on durable
33
+ *
34
+ * Every group has its own cursor over the ONE ring, so fan-out subscribers each
35
+ * see everything and competing consumers of one group share the work.
36
+ *
37
+ * ORDERING is FIFO per (queue, partition) within one ownership incarnation.
38
+ * Across an incarnation boundary the question is empty: the contents are gone.
39
+ *
40
+ * AND THE TWO KINDS OF 404, WHICH MUST NEVER BE CONFUSED FOR EACH OTHER. No SDK
41
+ * negotiates a version, so against a broker or proxy older than 1.1 the whole
42
+ * family answers 404 -- the broker because the routes do not exist, the proxy
43
+ * because an unknown API path is `route_blocked`. That is a DEPLOYMENT fact and
44
+ * arrives as `.code === EPHEMERAL_UNSUPPORTED`.
45
+ *
46
+ * But `depth` also answers a real 404, with `code: 'ephemeral_queue_not_found'`,
47
+ * when the queue simply is not there -- and it is the only verb that can, since
48
+ * push and pop create implicitly, `reset` answers `dropped:0` and `delete`
49
+ * answers `deleted:false`. That is a DATA fact and arrives as
50
+ * `.code === EPHEMERAL_QUEUE_NOT_FOUND`. Collapsing it into the first would
51
+ * send somebody chasing a broker version over a queue name typo.
52
+ *
53
+ * Both keep the broker's own error as `.cause`. Branch on the code, never on
54
+ * the prose.
55
+ */
56
+
57
+ import * as logger from '../utils/logger.js'
58
+ import { EPHEMERAL_SINK, ephemeralAddress, ephemeralDestination } from '../buffer/sinks.js'
59
+
60
+ /** `error.code` on the old-broker error, so callers branch on a code, not a message. */
61
+ export const EPHEMERAL_UNSUPPORTED = 'ephemeral_unsupported'
62
+
63
+ /** The message every SDK fixes for this case (§4). Keep it identical across clients. */
64
+ export const EPHEMERAL_UNSUPPORTED_MESSAGE =
65
+ 'broker/proxy does not support ephemeral queues (requires >= 1.1)'
66
+
67
+ /**
68
+ * `error.code` when the queue itself does not exist -- the broker's own code
69
+ * string, kept identical across every SDK (Go `ErrEphemeralQueueNotFound`,
70
+ * `queen-protocol::EPHEMERAL_QUEUE_NOT_FOUND_CODE`) so a code seen in one
71
+ * language's logs means the same thing in the next.
72
+ */
73
+ export const EPHEMERAL_QUEUE_NOT_FOUND = 'ephemeral_queue_not_found'
74
+
75
+ /**
76
+ * The seven knobs of `configure` (§3.1). A CLOSED list: an option this client
77
+ * does not know is refused rather than dropped on the floor, because every one
78
+ * of these bounds something (bytes, length, age, redelivery) and a silently
79
+ * ignored `ttlSecond` is a ring that grows until a global budget answers 503.
80
+ */
81
+ const CONFIGURE_OPTIONS = [
82
+ 'maxBytes',
83
+ 'maxLength',
84
+ 'policy',
85
+ 'ttlSeconds',
86
+ 'leaseSeconds',
87
+ 'retryLimit',
88
+ 'windowBuffer'
89
+ ]
90
+
91
+ /** Long-poll default, matching the durable pop's, when `wait` is asked for without a timeout. */
92
+ const DEFAULT_WAIT_TIMEOUT_MILLIS = 30000
93
+
94
+ // The HTTP deadline must outlive the server's own long-poll timeout, or the
95
+ // client aborts a request the broker was about to answer. Same 5s slack the
96
+ // durable pop uses.
97
+ const WAIT_TIMEOUT_SLACK_MILLIS = 5000
98
+
99
+ function requireQueue(queue) {
100
+ if (typeof queue !== 'string' || queue.length === 0) {
101
+ throw new Error(`ephemeral: queue must be a non-empty string, got ${JSON.stringify(queue)}`)
102
+ }
103
+ return queue
104
+ }
105
+
106
+ /**
107
+ * Two facts arrive on this family as 404, and telling them apart is the whole
108
+ * job of this function. THE BODY'S CODE decides, not the status:
109
+ *
110
+ * * `ephemeral_queue_not_found` -- the routes are there and answered; the
111
+ * QUEUE is not. Only `depth` can say this (§3.1): push and pop create
112
+ * implicitly, `reset` answers `dropped:0`, `delete` answers
113
+ * `deleted:false`. It is checked for on every verb anyway, because which
114
+ * verbs can say it is the broker's business and this client should not
115
+ * re-encode that list.
116
+ * * anything else -- an old broker that never registered the routes, or an
117
+ * old proxy answering `route_blocked` because it fails closed on unknown
118
+ * API paths (§4, §8). Both mean "upgrade".
119
+ *
120
+ * The broker's own error is kept as `.cause` either way, so nothing the HTTP
121
+ * layer surfaced is lost by the mapping.
122
+ */
123
+ function map404(error, queue) {
124
+ if (!error || error.status !== 404) return error
125
+
126
+ if (error.code === EPHEMERAL_QUEUE_NOT_FOUND) {
127
+ const missing = new Error(
128
+ queue
129
+ ? `ephemeral: queue "${queue}" does not exist`
130
+ : 'ephemeral: that queue does not exist'
131
+ )
132
+ missing.code = EPHEMERAL_QUEUE_NOT_FOUND
133
+ missing.status = 404
134
+ if (queue) missing.queue = queue
135
+ missing.cause = error
136
+ return missing
137
+ }
138
+
139
+ const mapped = new Error(EPHEMERAL_UNSUPPORTED_MESSAGE)
140
+ mapped.code = EPHEMERAL_UNSUPPORTED
141
+ mapped.status = 404
142
+ mapped.cause = error
143
+ return mapped
144
+ }
145
+
146
+ /** Options are forwarded in a fixed order, and only when the caller gave them. */
147
+ function buildConfigureOptions(options) {
148
+ const given = options || {}
149
+ if (typeof given !== 'object' || Array.isArray(given)) {
150
+ throw new Error(`ephemeral: configure options must be an object, got ${JSON.stringify(options)}`)
151
+ }
152
+
153
+ const unknown = Object.keys(given).filter(key => !CONFIGURE_OPTIONS.includes(key))
154
+ if (unknown.length > 0) {
155
+ throw new Error(
156
+ `ephemeral: unknown configure option(s) ${unknown.join(', ')} — ` +
157
+ `an option this client does not know would be silently dropped. Known options: ${CONFIGURE_OPTIONS.join(', ')}`
158
+ )
159
+ }
160
+
161
+ const out = {}
162
+ for (const key of CONFIGURE_OPTIONS) {
163
+ if (given[key] !== undefined) out[key] = given[key]
164
+ }
165
+ return out
166
+ }
167
+
168
+ /**
169
+ * One message on the ephemeral wire is `{payload}` and nothing else -- no
170
+ * transactionId, because there is no dedup index to hold one, and no queue or
171
+ * partition, because the envelope already carries them.
172
+ *
173
+ * The `{data:...}` / `{payload:...}` sugar is the durable push's, deliberately
174
+ * reproduced (QueueBuilder.push) so one mental model covers both families,
175
+ * INCLUDING its trap: an object that happens to have a `data` key is read as
176
+ * the sugar, and its other keys do not travel. Wrap it -- `{payload: obj}` --
177
+ * when the object is the payload.
178
+ */
179
+ function toMessage(item) {
180
+ if (item === undefined || item === null) {
181
+ throw new Error('ephemeral: a message may not be null or undefined — write { payload: null } to push a null payload')
182
+ }
183
+ if (typeof item === 'object' && !Array.isArray(item)) {
184
+ if ('payload' in item) return { payload: item.payload }
185
+ if ('data' in item) return { payload: item.data }
186
+ }
187
+ return { payload: item }
188
+ }
189
+
190
+ function toMessages(messages) {
191
+ const list = Array.isArray(messages) ? messages : [messages]
192
+ return list.map(toMessage)
193
+ }
194
+
195
+ /** `true`/`false` are sugar for the two statuses people actually mean. */
196
+ function normalizeStatus(status) {
197
+ if (typeof status === 'boolean') return status ? 'completed' : 'failed'
198
+ return status
199
+ }
200
+
201
+ /**
202
+ * An ack is `{id, status?, error?}`. Accepts a popped message, a bare id
203
+ * string, or the wire object itself; a per-entry status wins over the call-wide
204
+ * default, which is how a mixed batch (some completed, one retry) is expressed
205
+ * in a single request.
206
+ */
207
+ function toAcks(acks, opts) {
208
+ const list = Array.isArray(acks) ? acks : [acks]
209
+ return list.map((entry, index) => {
210
+ const isObject = entry !== null && typeof entry === 'object'
211
+ const id = typeof entry === 'string' ? entry : (isObject ? entry.id : null)
212
+ if (typeof id !== 'string' || id.length === 0) {
213
+ throw new Error(`ephemeral: ack at index ${index} carries no message id — pass the popped message, or its \`id\``)
214
+ }
215
+
216
+ const ack = { id }
217
+
218
+ const status = isObject && entry.status !== undefined ? entry.status : opts.status
219
+ if (status !== undefined && status !== null) ack.status = normalizeStatus(status)
220
+
221
+ const error = isObject && entry.error !== undefined ? entry.error : opts.error
222
+ if (error !== undefined && error !== null) ack.error = error
223
+
224
+ return ack
225
+ })
226
+ }
227
+
228
+ export class Ephemeral {
229
+ #httpClient
230
+ #bufferManager
231
+
232
+ constructor(httpClient, bufferManager = null) {
233
+ this.#httpClient = httpClient
234
+ this.#bufferManager = bufferManager
235
+ }
236
+
237
+ /**
238
+ * Every request in this class goes through here, so the two 404 rules have
239
+ * one home. `queue` is passed only so a missing-queue error can name it.
240
+ */
241
+ async #call(method, path, body = null, { timeoutMillis = null, affinityKey = null, retryKind = null, queue = null } = {}) {
242
+ try {
243
+ if (method === 'GET') return await this.#httpClient.get(path, timeoutMillis, affinityKey, retryKind)
244
+ if (method === 'DELETE') return await this.#httpClient.delete(path, timeoutMillis, affinityKey, retryKind)
245
+ return await this.#httpClient.post(path, body, timeoutMillis, affinityKey, retryKind)
246
+ } catch (error) {
247
+ logger.error('Ephemeral.request', { method, path, status: error.status, error: error.message, code: error.code })
248
+ throw map404(error, queue)
249
+ }
250
+ }
251
+
252
+ // ------------------------------------------------------------ declaration
253
+
254
+ /**
255
+ * Declare a queue and its bounds. Persists the OPTIONS in PG (§1.1): the
256
+ * configuration survives a restart, the contents never do, and the queue
257
+ * comes back declared and empty.
258
+ *
259
+ * Optional in every sense -- a push or a pop that names an unknown queue
260
+ * creates it implicitly with the tenant defaults (§1.1). Declare when you
261
+ * want non-default bounds, or when you want the queue to exist in the
262
+ * dashboard before its first message.
263
+ *
264
+ * Options, all optional: `maxBytes` / `maxLength` (the per-queue budget, with
265
+ * `policy` deciding whether breaching it rejects the push with 429 or drops
266
+ * the OLDEST message -- feed semantics), `ttlSeconds` (drop messages older
267
+ * than this; it is NOT the durable `retention`, which cleans consumed history
268
+ * and never touches pending), `leaseSeconds` and `retryLimit` (redelivery),
269
+ * `windowBuffer {ms, count}` (let a waiting pop fatten its batch).
270
+ */
271
+ async configure(queue, options = {}) {
272
+ requireQueue(queue)
273
+ const body = { queue, options: buildConfigureOptions(options) }
274
+ logger.log('Ephemeral.configure', { queue, options: Object.keys(body.options) })
275
+ return this.#call('POST', '/api/v1/ephemeral/configure', body, { queue })
276
+ }
277
+
278
+ /**
279
+ * Drop every message, void every lease, rewind every group cursor. Returns
280
+ * `{dropped}`.
281
+ *
282
+ * A verb that would be indefensible on a durable queue and is merely honest
283
+ * here: it destroys nothing the class ever promised to keep (§1.2). The
284
+ * declared configuration stays.
285
+ */
286
+ async reset(queue) {
287
+ requireQueue(queue)
288
+ logger.log('Ephemeral.reset', { queue })
289
+ return this.#call('POST', '/api/v1/ephemeral/reset', { queue }, { queue })
290
+ }
291
+
292
+ /** Delete the queue: contents, cursors, and the declared configuration in PG. */
293
+ async delete(queue) {
294
+ requireQueue(queue)
295
+ logger.log('Ephemeral.delete', { queue })
296
+ return this.#call('DELETE', `/api/v1/ephemeral/queue/${encodeURIComponent(queue)}`, null, { queue })
297
+ }
298
+
299
+ // ------------------------------------------------------------------ push
300
+
301
+ /**
302
+ * Push one message or many. All-or-nothing per request; returns `{pushed}`.
303
+ *
304
+ * await queen.ephemeral.push('presence', [{ user: 'a', typing: true }])
305
+ * await queen.ephemeral.push('presence', msgs, { partition: 'room-7' })
306
+ *
307
+ * `opts.partition` picks the ring (FIFO is per partition, §1.4); omitted, the
308
+ * broker picks and this client does not invent a default.
309
+ *
310
+ * `opts.buffered {intervalMillis, messageCount, maxSize}` batches client-side
311
+ * through the SAME machinery the durable push uses (§4.1) -- blocking
312
+ * backpressure at `maxSize`, a failed batch back at the FRONT and retried,
313
+ * `queen.close()` draining it with a deadline. It resolves to
314
+ * `{buffered:true, count}` once the messages are IN the buffer, not once they
315
+ * are at the broker, and a buffered message that has not flushed dies with
316
+ * the process. That last part is already inside this class's contract, which
317
+ * is exactly why buffering is a reasonable default here and a considered
318
+ * decision on a durable queue.
319
+ */
320
+ async push(queue, messages, opts = {}) {
321
+ requireQueue(queue)
322
+ const items = toMessages(messages)
323
+ if (items.length === 0) return { pushed: 0 }
324
+
325
+ const partition = opts.partition ?? null
326
+
327
+ if (opts.buffered) {
328
+ if (!this.#bufferManager) {
329
+ throw new Error('ephemeral: buffered push needs the client\'s buffer manager — use queen.ephemeral, not a hand-built Ephemeral')
330
+ }
331
+ return this.#pushBuffered(queue, partition, items, opts)
332
+ }
333
+
334
+ const body = { queue }
335
+ if (partition !== null) body.partition = partition
336
+ body.messages = items
337
+
338
+ logger.log('Ephemeral.push', { queue, partition, count: items.length })
339
+ return this.#call('POST', EPHEMERAL_SINK.path, body, { queue })
340
+ }
341
+
342
+ /**
343
+ * The buffered variant. One buffer per `eph:<queue>/<partition>` address, so
344
+ * an ephemeral queue and a durable queue of the same name never share a
345
+ * buffer or a drain (§4.1).
346
+ */
347
+ async #pushBuffered(queue, partition, items, opts) {
348
+ const address = ephemeralAddress(queue, partition)
349
+ const destination = ephemeralDestination(queue, partition)
350
+ const bufferOptions = bufferOptionsFrom(opts.buffered)
351
+ const accepted = []
352
+
353
+ // Awaited one at a time: addMessage is where the maxSize bound blocks, so a
354
+ // buffered push that resolved without awaiting would report success for
355
+ // messages the buffer never accepted.
356
+ try {
357
+ for (const item of items) {
358
+ await this.#bufferManager.addMessage(address, item, bufferOptions, { signal: opts.signal, destination })
359
+ accepted.push(item)
360
+ }
361
+ } catch (error) {
362
+ logger.error('Ephemeral.push', {
363
+ queue,
364
+ partition,
365
+ status: 'not-buffered',
366
+ count: items.length - accepted.length,
367
+ error: error.message
368
+ })
369
+ throw error
370
+ }
371
+
372
+ logger.log('Ephemeral.push', { queue, partition, status: 'buffered', count: accepted.length })
373
+ return { buffered: true, count: accepted.length }
374
+ }
375
+
376
+ /** Send everything buffered for one ephemeral queue/partition, now. */
377
+ async flush(queue, opts = {}) {
378
+ requireQueue(queue)
379
+ if (!this.#bufferManager) return
380
+ const address = ephemeralAddress(queue, opts.partition ?? null)
381
+ logger.log('Ephemeral.flush', { queue, partition: opts.partition ?? null })
382
+ await this.#bufferManager.flushBuffer(address, { deadlineMillis: opts.deadlineMillis ?? null })
383
+ }
384
+
385
+ // ------------------------------------------------------------------- pop
386
+
387
+ /**
388
+ * Take up to `batch` messages. Resolves to `{queue, messages}`, with
389
+ * `messages` an EMPTY ARRAY when there was nothing -- never null, so the
390
+ * destructure is always safe:
391
+ *
392
+ * const { messages } = await queen.ephemeral.pop('inbox', { wait: true })
393
+ *
394
+ * Each message is `{id, partition, payload, attempts}`. The `id` is opaque:
395
+ * it encodes the owning broker incarnation, which is what lets an ack that
396
+ * arrives after a restart or an ownership move answer `stale` instead of
397
+ * acking somebody else's message.
398
+ *
399
+ * `wait:true` is a real long poll, parked on a RAM gate with no database
400
+ * behind it and no polling interval anywhere (§3.4) -- the structural reason
401
+ * an ephemeral inbox answers in transport time. `timeout` is milliseconds
402
+ * (default 30000 when waiting), and the HTTP deadline is set past it so the
403
+ * broker's timeout always fires first.
404
+ *
405
+ * `group` is the whole of the consumption semantics (§1.5): same group =
406
+ * competing consumers, own group = fan-out, no group = queue mode.
407
+ * `autoAck:true` commits at delivery and is at-most-once.
408
+ */
409
+ async pop(queue, opts = {}) {
410
+ requireQueue(queue)
411
+
412
+ const partition = opts.partition ?? null
413
+ const group = opts.group ?? null
414
+ const wait = opts.wait === true
415
+ const timeoutMillis = resolveTimeout(opts)
416
+
417
+ const params = new URLSearchParams({ queue })
418
+ if (partition !== null) params.append('partition', partition)
419
+ if (opts.batch !== undefined && opts.batch !== null) params.append('batch', String(opts.batch))
420
+ // Sent only when true, so a plain pop is the shortest query this route can
421
+ // receive and the broker's own defaults own everything else.
422
+ if (wait) {
423
+ params.append('wait', 'true')
424
+ params.append('timeout', String(timeoutMillis))
425
+ }
426
+ if (group !== null) params.append('group', group)
427
+ if (opts.autoAck === true) params.append('autoAck', 'true')
428
+
429
+ logger.log('Ephemeral.pop', { queue, partition, group, batch: opts.batch ?? null, wait })
430
+
431
+ // Affinity so repeated pops of one queue land on one backend when the
432
+ // client holds several URLs: the broker forwards to the rendezvous owner
433
+ // either way, so this saves a hop, it does not create correctness.
434
+ const affinityKey = `${queue}:${partition || '*'}:${group || '__QUEUE_MODE__'}`
435
+ const result = await this.#call('GET', `/api/v1/ephemeral/pop?${params}`, null, {
436
+ queue,
437
+ timeoutMillis: wait ? timeoutMillis + WAIT_TIMEOUT_SLACK_MILLIS : null,
438
+ affinityKey,
439
+ // A long poll that meets a 429 should back off and keep waiting rather
440
+ // than give up after a handful of tries.
441
+ retryKind: wait ? 'pop' : null
442
+ })
443
+
444
+ const messages = result && Array.isArray(result.messages) ? result.messages.filter(m => m != null) : []
445
+ logger.log('Ephemeral.pop', { queue, status: messages.length > 0 ? 'success' : 'empty', count: messages.length })
446
+ return { queue: (result && result.queue) || queue, messages }
447
+ }
448
+
449
+ // ------------------------------------------------------------------- ack
450
+
451
+ /**
452
+ * Acknowledge popped messages. Returns `{results:[{id, outcome}]}` with
453
+ * `outcome ∈ {acked, redelivered, stale, unknown}`.
454
+ *
455
+ * await queen.ephemeral.ack('inbox', messages, { group: 'workers' })
456
+ * await queen.ephemeral.ack('inbox', [{ id, status: 'retry' }])
457
+ *
458
+ * `stale` is NOT an error and never arrives as one: it is the answer to an
459
+ * ack whose message belonged to a previous incarnation of the ring, which is
460
+ * how this class fences a restart or an ownership move without a lease
461
+ * protocol. Pass the same `group` the pop used -- cursors are per group.
462
+ *
463
+ * `status` is `completed` (default), `failed` or `retry`; `false` is sugar
464
+ * for `failed`. A failed or retried message comes back with `attempts+1`
465
+ * until `retryLimit`, then it is dropped and counted. There is no DLQ.
466
+ */
467
+ async ack(queue, acks, opts = {}) {
468
+ requireQueue(queue)
469
+ const list = toAcks(acks, opts)
470
+ if (list.length === 0) return { results: [] }
471
+
472
+ const body = { queue }
473
+ if (opts.group !== undefined && opts.group !== null) body.group = opts.group
474
+ body.acks = list
475
+
476
+ logger.log('Ephemeral.ack', { queue, group: opts.group ?? null, count: list.length })
477
+ return this.#call('POST', '/api/v1/ephemeral/ack', body, { queue })
478
+ }
479
+
480
+ // ---------------------------------------------------------------- status
481
+
482
+ /**
483
+ * Every ephemeral queue this tenant currently has, declared and implicit.
484
+ *
485
+ * Free to poll: the gauges are read out of the broker's own memory, with no
486
+ * database behind them -- unlike the durable meter, whose 1s poll is
487
+ * load-bearing on PG.
488
+ */
489
+ async queues() {
490
+ logger.log('Ephemeral.queues', {})
491
+ return this.#call('GET', '/api/v1/ephemeral/queues')
492
+ }
493
+
494
+ /**
495
+ * Depth gauges for one queue: ring length, bytes, and the per-group cursors.
496
+ *
497
+ * THE ONLY VERB THAT CAN TELL YOU A QUEUE IS MISSING. Everything else either
498
+ * creates the queue (push, pop) or answers a normal body about having done
499
+ * nothing (`reset` -> `dropped:0`, `delete` -> `deleted:false`). Here an
500
+ * unknown queue raises with `.code === EPHEMERAL_QUEUE_NOT_FOUND` -- a
501
+ * different fact from `EPHEMERAL_UNSUPPORTED`, which is about the broker's
502
+ * version, and worth distinguishing precisely because both are 404s.
503
+ */
504
+ async depth(queue) {
505
+ requireQueue(queue)
506
+ logger.log('Ephemeral.depth', { queue })
507
+ return this.#call('GET', `/api/v1/ephemeral/queues/${encodeURIComponent(queue)}/depth`, null, { queue })
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Buffer options are `QueueBuilder.buffer()`'s, unchanged (§4.1): the two
513
+ * families share the machinery, so they share its vocabulary --
514
+ * `{messageCount, timeMillis, maxSize, retryDelayMillis}`. `intervalMillis` is
515
+ * accepted as a spelling of `timeMillis` because it is the name the ephemeral
516
+ * plan's API sketch used; it is TRANSLATED rather than passed through, since
517
+ * MessageBuffer carries unknown keys untouched and would silently ignore it --
518
+ * a linger option that quietly does nothing is a producer that batches on count
519
+ * alone and stalls below the threshold. Both spellings at once is refused.
520
+ */
521
+ function bufferOptionsFrom(buffered) {
522
+ const given = buffered === true ? {} : (buffered || {})
523
+ if (typeof given !== 'object' || Array.isArray(given)) {
524
+ throw new Error(`ephemeral: \`buffered\` must be true or an options object, got ${JSON.stringify(buffered)}`)
525
+ }
526
+
527
+ const { intervalMillis, ...options } = given
528
+ if (intervalMillis !== undefined) {
529
+ if (options.timeMillis !== undefined) {
530
+ throw new Error('ephemeral: pass either `timeMillis` or `intervalMillis`, not both — they are the same linger')
531
+ }
532
+ options.timeMillis = intervalMillis
533
+ }
534
+ return options
535
+ }
536
+
537
+ /**
538
+ * `timeout` is the wire's name and milliseconds is the SDK's unit, so both
539
+ * spellings are accepted -- and BOTH AT ONCE is refused rather than silently
540
+ * resolved, the same rule the KV expiry sugar follows.
541
+ */
542
+ function resolveTimeout(opts) {
543
+ const hasTimeout = opts.timeout !== undefined && opts.timeout !== null
544
+ const hasMillis = opts.timeoutMillis !== undefined && opts.timeoutMillis !== null
545
+ if (hasTimeout && hasMillis) {
546
+ throw new Error('ephemeral: pass either `timeout` or `timeoutMillis`, not both — they are the same milliseconds')
547
+ }
548
+ if (hasTimeout) return opts.timeout
549
+ if (hasMillis) return opts.timeoutMillis
550
+ return DEFAULT_WAIT_TIMEOUT_MILLIS
551
+ }
@@ -16,7 +16,19 @@
16
16
 
17
17
  export { Queen } from './Queen.js'
18
18
  export { Admin } from './admin/Admin.js'
19
+ // RAM-class queues (EPHEMERAL_QUEUES.md §4). Reached as `queen.ephemeral`; the
20
+ // class is exported for typing and for embedding it on a client of your own.
21
+ // The two `.code`s the family's 404s carry, which are NOT the same fact:
22
+ // `EPHEMERAL_UNSUPPORTED` is a broker or proxy older than 1.1 (the routes are
23
+ // not there), `EPHEMERAL_QUEUE_NOT_FOUND` is `depth` on a queue that does not
24
+ // exist. Branch on the code, never on the message.
25
+ export { Ephemeral, EPHEMERAL_UNSUPPORTED, EPHEMERAL_QUEUE_NOT_FOUND } from './ephemeral/Ephemeral.js'
19
26
  export { CLIENT_DEFAULTS, QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS, BUFFER_DEFAULTS } from './utils/defaults.js'
27
+ // Conflation degrade-loudly (PLAN_CONFLATION §4): a consumer that declares
28
+ // `.conflation(true)` against a broker older than 1.1.0 gets an error with this
29
+ // `.code`, so callers can branch on it (alert, fall back, exit) without
30
+ // matching on the message text.
31
+ export { CONFLATION_UNSUPPORTED } from './utils/conflation.js'
20
32
 
21
33
  // Streaming SDK — full source under ./streams/.
22
34
  export { Stream } from './streams/Stream.js'
@@ -260,6 +260,9 @@ export class Stream {
260
260
  * @param {number} [runOptions.maxWaitMillis=1000] - long-poll wait for source pop
261
261
  * @param {string} [runOptions.subscriptionMode] - 'all' (default) | 'new'
262
262
  * @param {string} [runOptions.subscriptionFrom] - ISO timestamp or 'now'
263
+ * @param {boolean} [runOptions.conflation=false] - last-value delivery on the
264
+ * source pop: each cycle sees only the newest visible message per partition
265
+ * (PLAN_CONFLATION §1.1). Requires broker >= 1.1.0
263
266
  * @param {boolean} [runOptions.reset=false] - wipe state on config_hash mismatch
264
267
  * @param {(err:Error, ctx:object)=>void} [runOptions.onError] - cycle error hook
265
268
  * @param {AbortSignal} [runOptions.abortSignal] - external cancellation
@@ -39,6 +39,7 @@ import { commitCycle } from './cycle.js'
39
39
  import { stateKeyFor, parseStateKey } from '../operators/ReduceOperator.js'
40
40
  import { makeLogger } from '../util/logger.js'
41
41
  import { sleep } from '../util/backoff.js'
42
+ import { CONFLATION_UNSUPPORTED } from '../../utils/conflation.js'
42
43
 
43
44
  const DEFAULT_BATCH_SIZE = 200
44
45
  const DEFAULT_MAX_PARTITIONS = 4
@@ -60,6 +61,14 @@ export class Runner {
60
61
  this.maxWaitMillis = opts.maxWaitMillis != null ? opts.maxWaitMillis : 1000
61
62
  this.subscriptionMode = opts.subscriptionMode || null
62
63
  this.subscriptionFrom = opts.subscriptionFrom || null
64
+ // Last-value delivery on the SOURCE pop (PLAN_CONFLATION §1.1): each cycle
65
+ // sees only the newest visible message per partition. Off by default. The
66
+ // query's own cursor/state path is untouched — this is the ordinary pop
67
+ // parameter, declared on the runner's consumer group like any other
68
+ // consumer's. Requires broker >= 1.1.0; an older one is detected from the
69
+ // missing response echo and stops the loop rather than quietly replaying
70
+ // every stale intermediate through the operators (§4).
71
+ this.conflation = !!opts.conflation
63
72
  this.reset = !!opts.reset
64
73
  this.onError = opts.onError || null
65
74
  this.abortSignal = opts.abortSignal || null
@@ -190,6 +199,14 @@ export class Runner {
190
199
  }
191
200
  } catch (err) {
192
201
  this._reportError(err, { phase: 'pop' })
202
+ // A broker that cannot apply the declared conflation policy is a
203
+ // permanent fault, not a transient one (PLAN_CONFLATION §4): retrying
204
+ // every 500ms would turn "your last-value policy is not in force" into
205
+ // a log line nobody reads while the operators chew the whole backlog.
206
+ if (err && err.code === CONFLATION_UNSUPPORTED) {
207
+ this._stopped = true
208
+ break
209
+ }
193
210
  await sleep(500)
194
211
  }
195
212
  }
@@ -223,11 +240,17 @@ export class Runner {
223
240
  .timeoutMillis(this.maxWaitMillis)
224
241
  .group(this.consumerGroup)
225
242
 
226
- if (this.maxPartitions > 1) {
227
- qb = qb.partitions(this.maxPartitions)
228
- }
243
+ // Unconditional, and it has to be: the runtime has already defaulted
244
+ // maxPartitions (to 4), so this is always a decision the streams layer
245
+ // made. Skipping the call for maxPartitions === 1 used to be a harmless
246
+ // optimisation -- 1 was what an omitted `partitions` meant on the wire --
247
+ // but with pop autopilot an omitted `partitions` means "broker, you
248
+ // choose", which would widen a query that explicitly asked for one
249
+ // partition per pop.
250
+ qb = qb.partitions(this.maxPartitions)
229
251
  if (this.subscriptionMode) qb = qb.subscriptionMode(this.subscriptionMode)
230
252
  if (this.subscriptionFrom) qb = qb.subscriptionFrom(this.subscriptionFrom)
253
+ if (this.conflation) qb = qb.conflation(true)
231
254
  return qb.pop()
232
255
  }
233
256