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
|
@@ -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
|
+
}
|
package/client-v2/index.js
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
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
|
|