queen-mq 0.16.0 → 1.0.3
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 +175 -0
- package/client-v2/Queen.js +125 -15
- package/client-v2/README.md +69 -0
- package/client-v2/builders/QueueBuilder.js +18 -20
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +199 -10
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +12 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +265 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +319 -0
- package/test-v2/kv-unit/_planServer.js +65 -0
- package/test-v2/kv-unit/kvWire.test.js +377 -0
- package/test-v2/kv-unit/timerWire.test.js +177 -0
- package/test-v2/kv-unit/txnWire.test.js +222 -0
- package/test-v2/kv.js +273 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/push.js +25 -35
- package/test-v2/run.js +67 -5
- package/test-v2/semantics.js +801 -0
- package/test-v2/stream/_helpers.js +8 -1
- package/test-v2/stream/combined.js +4 -3
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/eventTime.js +8 -5
- package/test-v2/stream/operators.js +5 -5
- package/test-v2/stream/recovery.js +4 -1
- package/test-v2/stream/session.js +3 -3
- package/test-v2/stream/sliding.js +1 -1
- package/test-v2/stream/throughput.js +3 -2
- package/test-v2/stream/tumbling.js +16 -12
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -1
- package/test-v2/watermark.js +78 -62
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timers (PLAN_KV_TIMERS.md §4, §8.1, §9.6).
|
|
3
|
+
*
|
|
4
|
+
* A timer is a message you promise now and the broker delivers later, into a
|
|
5
|
+
* real queue, through the real log. Four terminals, all EXPLICIT -- nothing on
|
|
6
|
+
* this builder does anything until one of them is called:
|
|
7
|
+
*
|
|
8
|
+
* await queen.timer('orders').key('order-9f1').delay('30s')
|
|
9
|
+
* .payload({ orderId: '9f1' }).schedule()
|
|
10
|
+
* await queen.timer('orders').key('order-9f1').cancel()
|
|
11
|
+
* await queen.timer('orders').key('order-9f1').peek()
|
|
12
|
+
* await queen.timer('orders').list({ limit: 50 })
|
|
13
|
+
*
|
|
14
|
+
* THE FOUR THINGS THIS FILE EXISTS TO GET RIGHT
|
|
15
|
+
*
|
|
16
|
+
* 1. CANCEL USES ITS OWN ROUTE, AND THAT IS NOT A DETAIL (§9.6).
|
|
17
|
+
* `DELETE /api/v1/timers/:queue/*timerKey` is the one route a proxy is
|
|
18
|
+
* forbidden to block; a cancel sent inside `POST /api/v1/timers` inherits
|
|
19
|
+
* the schedule's authorization instead. Since the FIRE never switches
|
|
20
|
+
* itself off, a tenant that can no longer cancel keeps producing messages it
|
|
21
|
+
* cannot stop -- a block there produces the exact opposite of its purpose.
|
|
22
|
+
* So `cancel()` on this builder always uses the DELETE route. (Inside a
|
|
23
|
+
* transaction it necessarily rides the bundle's array and inherits the
|
|
24
|
+
* bundle's fate; see TransactionBuilder, which says so where it happens.)
|
|
25
|
+
*
|
|
26
|
+
* 2. ONLY RELATIVE DURATIONS, IN MILLISECONDS (§4.2, §20.6). `delayMs`, never
|
|
27
|
+
* `delaySeconds` and never an absolute instant: `deliver_at` is computed in
|
|
28
|
+
* Postgres, so there is ONE clock and no broker's skew can enter. The rule
|
|
29
|
+
* of the product is "durations that can be sub-second are in milliseconds,
|
|
30
|
+
* the ones that cannot are in seconds" -- a 250 ms retry backoff is a real
|
|
31
|
+
* and central use of timers, which is why this wire is the millisecond one.
|
|
32
|
+
* A delay in the past is LEGAL and fires on the first cycle.
|
|
33
|
+
*
|
|
34
|
+
* 3. `deliverAt` IS "NOT BEFORE", NEVER "EXACTLY AT". The floor on this stack
|
|
35
|
+
* is a single hop (p50 ~10 ms, fsync ~4 ms) plus one sweep cycle.
|
|
36
|
+
*
|
|
37
|
+
* 4. THERE IS NO TOMBSTONE (§4.4). Once a timer has fired its row is gone, so a
|
|
38
|
+
* later cancel answers `absent` -- **which MAY mean it was already
|
|
39
|
+
* delivered**. `absent` carries `ok:false` for exactly that reason, and the
|
|
40
|
+
* response echoes the `txn` so the authority (the log, in the destination
|
|
41
|
+
* queue) can be consulted without a second API. Any saga that cancels a
|
|
42
|
+
* compensation timer must have the compensating consumer check the saga's
|
|
43
|
+
* KV state before compensating: otherwise "the timer went out 5 ms before
|
|
44
|
+
* the cancel" unwinds a booking that was already shipped.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import * as logger from '../utils/logger.js'
|
|
48
|
+
import { generateUUID } from './QueueBuilder.js'
|
|
49
|
+
import { parseDurationMs } from '../kv/expiry.js'
|
|
50
|
+
|
|
51
|
+
/** JSON payloads are encoded for the caller; raw bytes are passed through. */
|
|
52
|
+
function encodePayload(payload) {
|
|
53
|
+
if (payload === undefined) return undefined
|
|
54
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(payload)) {
|
|
55
|
+
return payload.toString('base64')
|
|
56
|
+
}
|
|
57
|
+
if (payload instanceof Uint8Array) {
|
|
58
|
+
return Buffer.from(payload).toString('base64')
|
|
59
|
+
}
|
|
60
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class TimerBuilder {
|
|
64
|
+
#httpClient
|
|
65
|
+
#queue
|
|
66
|
+
#sink
|
|
67
|
+
#timerKey = null
|
|
68
|
+
#partition = null
|
|
69
|
+
#delayMs = null
|
|
70
|
+
#payloadB64 = undefined
|
|
71
|
+
#txn = null
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `sink`, when present, receives the finished op instead of the network:
|
|
75
|
+
* that is how the same builder serves `queen.timer(...)` and
|
|
76
|
+
* `tx.timer(...)`, so the wire shape of a timer op is written once.
|
|
77
|
+
*/
|
|
78
|
+
constructor(httpClient, queue, sink = null) {
|
|
79
|
+
if (typeof queue !== 'string' || queue.length === 0) {
|
|
80
|
+
throw new Error('timer: a queue name is required — a timer without a queue has nowhere to be delivered')
|
|
81
|
+
}
|
|
82
|
+
this.#httpClient = httpClient
|
|
83
|
+
this.#queue = queue
|
|
84
|
+
this.#sink = sink
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The timer's identity inside its queue. `(queue, timerKey)` is the primary key. */
|
|
88
|
+
key(timerKey) {
|
|
89
|
+
if (typeof timerKey !== 'string' || timerKey.length === 0) {
|
|
90
|
+
throw new Error('timer: timerKey must be a non-empty string')
|
|
91
|
+
}
|
|
92
|
+
this.#timerKey = timerKey
|
|
93
|
+
return this
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Destination partition of the delivered message. Defaults to 'Default'. */
|
|
97
|
+
partition(name) {
|
|
98
|
+
this.#partition = name
|
|
99
|
+
return this
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fire no earlier than this many milliseconds from now. Negative is legal: it fires on the first cycle. */
|
|
103
|
+
delayMs(ms) {
|
|
104
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
105
|
+
throw new Error(`timer: delayMs must be a finite number of milliseconds, got ${JSON.stringify(ms)}`)
|
|
106
|
+
}
|
|
107
|
+
this.#delayMs = Math.round(ms)
|
|
108
|
+
return this
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The same delay as a duration string: '250ms', '30s', '2h'. Converted to delayMs here. */
|
|
112
|
+
delay(duration) {
|
|
113
|
+
this.#delayMs = Math.round(parseDurationMs(duration))
|
|
114
|
+
return this
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The message body. Any JSON value, or a Buffer/Uint8Array for raw bytes. */
|
|
118
|
+
payload(payload) {
|
|
119
|
+
this.#payloadB64 = encodePayload(payload)
|
|
120
|
+
return this
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The transaction id the delivered message will carry. Minted when absent.
|
|
125
|
+
*
|
|
126
|
+
* §20.2: every reschedule overwrites it, because a rescheduled timer is a
|
|
127
|
+
* NEW message -- and the corollary that must travel with it is that there is
|
|
128
|
+
* no dedup net on the fire at all, so rescheduling or republishing a timer
|
|
129
|
+
* that has already gone out produces a second message in the log and nothing
|
|
130
|
+
* stops it.
|
|
131
|
+
*/
|
|
132
|
+
txn(transactionId) {
|
|
133
|
+
this.#txn = transactionId
|
|
134
|
+
return this
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------ op shapes
|
|
138
|
+
|
|
139
|
+
#scheduleOp() {
|
|
140
|
+
if (!this.#timerKey) throw new Error('timer: key(...) is required to schedule')
|
|
141
|
+
if (this.#delayMs === null) {
|
|
142
|
+
throw new Error('timer: delayMs(...) or delay(...) is required — an absolute instant is not expressible on this wire')
|
|
143
|
+
}
|
|
144
|
+
if (this.#payloadB64 === undefined) throw new Error('timer: payload(...) is required to schedule')
|
|
145
|
+
const op = {
|
|
146
|
+
op: 'schedule',
|
|
147
|
+
queue: this.#queue,
|
|
148
|
+
timerKey: this.#timerKey
|
|
149
|
+
}
|
|
150
|
+
if (this.#partition !== null && this.#partition !== undefined) op.partition = this.#partition
|
|
151
|
+
op.delayMs = this.#delayMs
|
|
152
|
+
op.txn = this.#txn || generateUUID()
|
|
153
|
+
op.payload = this.#payloadB64
|
|
154
|
+
return op
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#cancelOp() {
|
|
158
|
+
if (!this.#timerKey) throw new Error('timer: key(...) is required to cancel')
|
|
159
|
+
const op = { op: 'cancel', queue: this.#queue, timerKey: this.#timerKey }
|
|
160
|
+
if (this.#txn) op.txn = this.#txn
|
|
161
|
+
return op
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#path(suffix = '') {
|
|
165
|
+
return `/api/v1/timers/${encodeURIComponent(this.#queue)}${suffix}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#keyPath() {
|
|
169
|
+
return this.#path(`/${encodeURIComponent(this.#timerKey)}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#single(body, what) {
|
|
173
|
+
if (!body || typeof body !== 'object' || !Array.isArray(body.results)) {
|
|
174
|
+
throw new Error(`timer: unexpected ${what} response envelope — expected {"results":[...]}`)
|
|
175
|
+
}
|
|
176
|
+
if (body.results.length !== 1) {
|
|
177
|
+
throw new Error(`timer: got ${body.results.length} results for 1 operation`)
|
|
178
|
+
}
|
|
179
|
+
return body.results[0]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------ terminals
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Schedule (or reschedule) this timer. An UPSERT on `(queue, timerKey)`, so
|
|
186
|
+
* a retry after a client crash is safe by construction; the answer says
|
|
187
|
+
* which it was, `status: 'scheduled' | 'rescheduled'`.
|
|
188
|
+
*
|
|
189
|
+
* A reschedule resets `attempts` and clears `last_error`: a rescheduled
|
|
190
|
+
* timer is a new timer under an old name, and a freshly corrected payload
|
|
191
|
+
* must not inherit the budget consumed by the one that was poisoning it.
|
|
192
|
+
*
|
|
193
|
+
* `too_late` (with `ok:false`) means the timer is already claimed by a
|
|
194
|
+
* broker that is about to commit it. Bounded by the sweeper lease. The
|
|
195
|
+
* remedy is a new key, or waiting for the delivery and acting on the message.
|
|
196
|
+
*/
|
|
197
|
+
// NOT `async`, deliberately: with a sink this terminal is the transaction's
|
|
198
|
+
// own chaining call and must hand back the TransactionBuilder itself, not a
|
|
199
|
+
// promise of it, or `.timer(q)...schedule().commit()` stops being a chain.
|
|
200
|
+
// The network path returns the promise, so `await ...schedule()` is
|
|
201
|
+
// unchanged for the standalone caller.
|
|
202
|
+
schedule() {
|
|
203
|
+
if (this.#sink) return this.#sink(this.#scheduleOp())
|
|
204
|
+
return this.#scheduleRemote()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The op is built INSIDE the async function so that a shape error on the
|
|
208
|
+
// network path is a rejected promise like every other failure of this
|
|
209
|
+
// client, and not a synchronous throw that `.catch()` would miss. On the
|
|
210
|
+
// transaction path it necessarily stays synchronous: there is no promise
|
|
211
|
+
// there to reject.
|
|
212
|
+
async #scheduleRemote() {
|
|
213
|
+
const op = this.#scheduleOp()
|
|
214
|
+
logger.log('TimerBuilder.schedule', { queue: this.#queue, timerKey: op.timerKey, delayMs: op.delayMs })
|
|
215
|
+
const body = await this.#httpClient.post('/api/v1/timers', { operations: [op] })
|
|
216
|
+
return this.#single(body, 'schedule')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Cancel this timer, through the route that is never blockable (§9.6).
|
|
221
|
+
*
|
|
222
|
+
* Idempotent: `absent` answers `ok:false` and MAY MEAN ALREADY DELIVERED --
|
|
223
|
+
* there is no tombstone. Pass `.txn(...)` and it comes back in the answer,
|
|
224
|
+
* so the log can settle the question.
|
|
225
|
+
*/
|
|
226
|
+
cancel() {
|
|
227
|
+
if (this.#sink) return this.#sink(this.#cancelOp())
|
|
228
|
+
return this.#cancelRemote()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async #cancelRemote() {
|
|
232
|
+
const op = this.#cancelOp()
|
|
233
|
+
logger.log('TimerBuilder.cancel', { queue: this.#queue, timerKey: op.timerKey })
|
|
234
|
+
const query = op.txn ? `?txn=${encodeURIComponent(op.txn)}` : ''
|
|
235
|
+
return this.#httpClient.delete(`${this.#keyPath()}${query}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read one pending timer, payload included. A miss is `{found:false}` with HTTP 200, never a 404. */
|
|
239
|
+
async peek() {
|
|
240
|
+
if (this.#sink) throw new Error('timer: peek is a read, not a transaction operation — use queen.timer(...).peek()')
|
|
241
|
+
if (!this.#timerKey) throw new Error('timer: key(...) is required to peek')
|
|
242
|
+
logger.log('TimerBuilder.peek', { queue: this.#queue, timerKey: this.#timerKey })
|
|
243
|
+
return this.#httpClient.get(this.#keyPath())
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* List the pending timers of this queue: `{rows, truncated, nextAfter}`.
|
|
248
|
+
*
|
|
249
|
+
* The queue is mandatory and that is why it is a path segment rather than a
|
|
250
|
+
* filter: a tenant-wide list would be a scan that an end user of the
|
|
251
|
+
* customer could trigger. `after` is an exclusive keyset cursor.
|
|
252
|
+
*/
|
|
253
|
+
async list(opts = {}) {
|
|
254
|
+
if (this.#sink) throw new Error('timer: list is a read, not a transaction operation — use queen.timer(...).list()')
|
|
255
|
+
const params = new URLSearchParams()
|
|
256
|
+
if (opts.after !== undefined && opts.after !== null && opts.after !== '') params.append('after', opts.after)
|
|
257
|
+
if (opts.limit !== undefined && opts.limit !== null) params.append('limit', String(opts.limit))
|
|
258
|
+
const query = params.toString()
|
|
259
|
+
logger.log('TimerBuilder.list', { queue: this.#queue, after: opts.after, limit: opts.limit })
|
|
260
|
+
return this.#httpClient.get(this.#path(query ? `?${query}` : ''))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -1,13 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transaction builder for atomic operations
|
|
3
|
+
*
|
|
4
|
+
* KV AND TIMER RIDERS (PLAN_KV_TIMERS.md §6.3, §8.2, §10.4)
|
|
5
|
+
*
|
|
6
|
+
* `kv` and `timers` are TOP-LEVEL fields of the request body, beside
|
|
7
|
+
* `operations` and NEVER elements of it. That is a wire decision, not a style
|
|
8
|
+
* one, and the reason is a silent failure in another language that this shape
|
|
9
|
+
* has to respect because one broker parses one wire for all seven clients: two
|
|
10
|
+
* Go struct fields carrying the same JSON key at the same level are BOTH
|
|
11
|
+
* DROPPED by encoding/json, with no error and no warning. A bundle would go
|
|
12
|
+
* out with zero kv operations, the broker would commit a transaction with no
|
|
13
|
+
* gate, and the `putIfAbsent` the bundle existed for would never have
|
|
14
|
+
* happened. An op that still arrives as `{"type":"kv"}` inside `operations`
|
|
15
|
+
* gets a named 400 from the broker, which is the best failure available.
|
|
16
|
+
*
|
|
17
|
+
* A bundle that carries neither array produces exactly the body it produces
|
|
18
|
+
* today -- no `kv: []`, no `timers: null` -- so nothing changes for anyone who
|
|
19
|
+
* does not use the feature, including brokers that predate it.
|
|
20
|
+
*
|
|
21
|
+
* WHY THE RIDERS ARE WORTH THE PARAGRAPH: the transaction is the PRIMARY
|
|
22
|
+
* fence, and `expect` only the secondary assertion. A KV write that shares the
|
|
23
|
+
* transaction with the ack is undone when an expired lease makes the ack fail
|
|
24
|
+
* -- something a CAS cannot do, because an `expect` on a still-matching
|
|
25
|
+
* version succeeds even from a zombie worker.
|
|
3
26
|
*/
|
|
4
27
|
|
|
5
28
|
import * as logger from '../utils/logger.js'
|
|
29
|
+
import { generateUUID } from './QueueBuilder.js'
|
|
30
|
+
import { isValidUUID } from '../utils/validation.js'
|
|
31
|
+
import { kvOp, materializeKvOp } from '../kv/Kv.js'
|
|
32
|
+
import { TimerBuilder } from './TimerBuilder.js'
|
|
6
33
|
|
|
7
34
|
export class TransactionBuilder {
|
|
8
35
|
#httpClient
|
|
9
36
|
#operations = []
|
|
10
37
|
#requiredLeases = []
|
|
38
|
+
// Unresolved on purpose: `until` is an instant, and freezing it when the op
|
|
39
|
+
// was queued would ship a TTL already stale by however long the bundle took
|
|
40
|
+
// to assemble. Materialized in commit(), which is send time.
|
|
41
|
+
#kvEntries = []
|
|
42
|
+
#timerOps = []
|
|
43
|
+
#kvApi = null
|
|
11
44
|
|
|
12
45
|
constructor(httpClient) {
|
|
13
46
|
this.#httpClient = httpClient
|
|
@@ -82,16 +115,26 @@ export class TransactionBuilder {
|
|
|
82
115
|
payloadValue = item
|
|
83
116
|
}
|
|
84
117
|
|
|
118
|
+
// Same contract as QueueBuilder.push: the caller's transactionId is
|
|
119
|
+
// what makes a retried transaction idempotent inside the dedup
|
|
120
|
+
// window, so it has to reach the wire. Absent, mint one here rather
|
|
121
|
+
// than leaving the broker to do it, so the id is knowable client
|
|
122
|
+
// side either way.
|
|
85
123
|
const result = {
|
|
86
124
|
queue: queueName,
|
|
87
|
-
payload: payloadValue
|
|
125
|
+
payload: payloadValue,
|
|
126
|
+
transactionId: item.transactionId || generateUUID()
|
|
88
127
|
}
|
|
89
|
-
|
|
128
|
+
|
|
90
129
|
// Add partition if set
|
|
91
130
|
if (partition !== null) {
|
|
92
131
|
result.partition = partition
|
|
93
132
|
}
|
|
94
133
|
|
|
134
|
+
if (item.traceId && isValidUUID(item.traceId)) {
|
|
135
|
+
result.traceId = item.traceId
|
|
136
|
+
}
|
|
137
|
+
|
|
95
138
|
return result
|
|
96
139
|
})
|
|
97
140
|
})
|
|
@@ -103,23 +146,169 @@ export class TransactionBuilder {
|
|
|
103
146
|
return subBuilder
|
|
104
147
|
}
|
|
105
148
|
|
|
149
|
+
// ===========================
|
|
150
|
+
// KV rider (§5, §8.2)
|
|
151
|
+
// ===========================
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* KV operations that commit with this transaction.
|
|
155
|
+
*
|
|
156
|
+
* await queen.transaction()
|
|
157
|
+
* .ack(msg)
|
|
158
|
+
* .kv.put('saga', sagaId, { step: 'charged' }, { ttl: '24h' })
|
|
159
|
+
* .commit()
|
|
160
|
+
*
|
|
161
|
+
* Every method returns the TRANSACTION, so the chain keeps reading like one.
|
|
162
|
+
* Same op shapes as `queen.kv`, built by the same code, so the two surfaces
|
|
163
|
+
* cannot drift.
|
|
164
|
+
*
|
|
165
|
+
* `getPrefix` is deliberately absent from this wire and throws here rather
|
|
166
|
+
* than at the broker: it is unbounded read work inside the transaction that
|
|
167
|
+
* holds the outermost lock space and, downstream, the partition ones. `get`
|
|
168
|
+
* and `getMany` are allowed because the caller fixes their cost -- the
|
|
169
|
+
* boundary is COST, not the kind of operation.
|
|
170
|
+
*/
|
|
171
|
+
get kv() {
|
|
172
|
+
if (this.#kvApi) return this.#kvApi
|
|
173
|
+
const add = (entry) => {
|
|
174
|
+
this.#kvEntries.push(entry)
|
|
175
|
+
return this
|
|
176
|
+
}
|
|
177
|
+
this.#kvApi = {
|
|
178
|
+
get: (ns, key) => add(kvOp.get(ns, key)),
|
|
179
|
+
getMany: (ns, keys) => add(kvOp.getMany(ns, keys)),
|
|
180
|
+
put: (ns, key, value, opts = {}) => add(kvOp.put(ns, key, value, opts)),
|
|
181
|
+
putIfAbsent: (ns, key, value, opts = {}) => add(kvOp.putIfAbsent(ns, key, value, opts)),
|
|
182
|
+
delete: (ns, key, opts = {}) => add(kvOp.delete(ns, key, opts)),
|
|
183
|
+
incr: (ns, key, delta = 1, opts = {}) => add(kvOp.incr(ns, key, delta, opts)),
|
|
184
|
+
getPrefix: () => {
|
|
185
|
+
throw new Error(
|
|
186
|
+
'kv: getPrefix is not available inside a transaction — unbounded read work under the outermost ' +
|
|
187
|
+
'lock space. Use POST /api/v1/kv (queen.kv.getPrefix / queen.kv.listAll) outside the bundle.'
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return this.#kvApi
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The gate, which is the reason this feature exists: do the bundle at most
|
|
196
|
+
* once.
|
|
197
|
+
*
|
|
198
|
+
* const res = await queen.transaction()
|
|
199
|
+
* .ack(msg)
|
|
200
|
+
* .queue('emails').push([{ data: mail }])
|
|
201
|
+
* .once('test-idem', orderId, { ttl: '24h' })
|
|
202
|
+
* .commit()
|
|
203
|
+
* if (res.success === false) return // a redelivery: already done
|
|
204
|
+
*
|
|
205
|
+
* `putIfAbsent` with `required:true`, so a marker that already exists ABORTS
|
|
206
|
+
* the transaction: the push and the ack roll back together with it. That is
|
|
207
|
+
* what makes "the email is sent exactly once" a property of the database
|
|
208
|
+
* rather than a hope about redelivery.
|
|
209
|
+
*
|
|
210
|
+
* The verdict is RETURNED by `commit()`, not thrown -- see there.
|
|
211
|
+
*/
|
|
212
|
+
once(ns, key, opts = {}) {
|
|
213
|
+
const value = opts.value !== undefined ? opts.value : true
|
|
214
|
+
const required = opts.required === false ? {} : { required: true }
|
|
215
|
+
return this.kv.putIfAbsent(ns, key, value, { ...opts, ...required })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ===========================
|
|
219
|
+
// Timer rider (§4, §9.6)
|
|
220
|
+
// ===========================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Schedule or cancel a timer as part of this transaction.
|
|
224
|
+
*
|
|
225
|
+
* await queen.transaction()
|
|
226
|
+
* .ack(msg)
|
|
227
|
+
* .timer('reminders').key(orderId).delay('24h').payload({ orderId }).schedule()
|
|
228
|
+
* .commit()
|
|
229
|
+
*
|
|
230
|
+
* `.schedule()` and `.cancel()` are the terminals; they add the op and hand
|
|
231
|
+
* the transaction back. `peek` and `list` are reads and are not transaction
|
|
232
|
+
* operations.
|
|
233
|
+
*
|
|
234
|
+
* ONE ASYMMETRY TO KNOW ABOUT (§9.6): a cancel sent this way rides the
|
|
235
|
+
* bundle and shares its fate, including its authorization. The cancel that
|
|
236
|
+
* is guaranteed never to be blocked is the standalone
|
|
237
|
+
* `queen.timer(q).key(k).cancel()`, on its own DELETE route. Cancel inside a
|
|
238
|
+
* bundle when you need atomicity with an ack; cancel outside it when you
|
|
239
|
+
* need the cancel to land no matter what.
|
|
240
|
+
*/
|
|
241
|
+
timer(queueName) {
|
|
242
|
+
return new TimerBuilder(null, queueName, (op) => {
|
|
243
|
+
this.#timerOps.push(op)
|
|
244
|
+
return this
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
106
248
|
async commit() {
|
|
107
|
-
|
|
249
|
+
const riderCount = this.#kvEntries.length + this.#timerOps.length
|
|
250
|
+
if (this.#operations.length === 0 && riderCount === 0) {
|
|
108
251
|
logger.error('TransactionBuilder.commit', 'No operations to commit')
|
|
109
252
|
throw new Error('Transaction has no operations to commit')
|
|
110
253
|
}
|
|
111
254
|
|
|
112
|
-
logger.log('TransactionBuilder.commit', {
|
|
255
|
+
logger.log('TransactionBuilder.commit', {
|
|
256
|
+
operationCount: this.#operations.length,
|
|
257
|
+
requiredLeases: this.#requiredLeases.length,
|
|
258
|
+
kv: this.#kvEntries.length,
|
|
259
|
+
timers: this.#timerOps.length
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// Byte-identity when the riders are absent (§6.3): the keys are added only
|
|
263
|
+
// when there is something in them, so a bundle that uses neither feature
|
|
264
|
+
// produces exactly the body it produced before this feature existed.
|
|
265
|
+
const body = {
|
|
266
|
+
operations: this.#operations,
|
|
267
|
+
requiredLeases: [...new Set(this.#requiredLeases)] // Unique leases
|
|
268
|
+
}
|
|
269
|
+
if (this.#kvEntries.length > 0) {
|
|
270
|
+
const now = Date.now()
|
|
271
|
+
body.kv = this.#kvEntries.map(e => materializeKvOp(e, now))
|
|
272
|
+
}
|
|
273
|
+
if (this.#timerOps.length > 0) {
|
|
274
|
+
body.timers = this.#timerOps
|
|
275
|
+
}
|
|
113
276
|
|
|
114
277
|
try {
|
|
115
|
-
const result = await this.#httpClient.post('/api/v1/transaction',
|
|
116
|
-
operations: this.#operations,
|
|
117
|
-
requiredLeases: [...new Set(this.#requiredLeases)] // Unique leases
|
|
118
|
-
})
|
|
278
|
+
const result = await this.#httpClient.post('/api/v1/transaction', body)
|
|
119
279
|
|
|
120
280
|
if (!result.success) {
|
|
121
|
-
|
|
122
|
-
|
|
281
|
+
// THE ONE OUTCOME THAT RETURNS INSTEAD OF THROWING (§8.3).
|
|
282
|
+
//
|
|
283
|
+
// A lost `required` KV precondition is not a failure: it is the
|
|
284
|
+
// EXPECTED outcome of every legitimate redelivery -- the idempotency
|
|
285
|
+
// marker doing its job -- and it arrives as HTTP 200 with
|
|
286
|
+
// `success:false, reason:'kv_precondition'` precisely so that it stays
|
|
287
|
+
// out of retry policies and error metrics. Throwing it would put the
|
|
288
|
+
// single most frequent outcome of this product inside every caller's
|
|
289
|
+
// catch block, where the natural reflex is to retry, which is the one
|
|
290
|
+
// thing that must not happen.
|
|
291
|
+
//
|
|
292
|
+
// The body carries `failedIndex` (in the FLAT index space of
|
|
293
|
+
// `results[]`), `kvReason`, `version` and `value`, so the caller can
|
|
294
|
+
// see who won without a second round trip.
|
|
295
|
+
if (result.reason === 'kv_precondition') {
|
|
296
|
+
logger.log('TransactionBuilder.commit', {
|
|
297
|
+
status: 'kv_precondition',
|
|
298
|
+
failedIndex: result.failedIndex,
|
|
299
|
+
kvReason: result.kvReason
|
|
300
|
+
})
|
|
301
|
+
return result
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
logger.error('TransactionBuilder.commit', { error: result.error, reason: result.reason })
|
|
305
|
+
const error = new Error(result.error || 'Transaction failed')
|
|
306
|
+
// The closed-taxonomy code travels ON the error, so no caller ever has
|
|
307
|
+
// to match the message: bad_request | duplicate | ack_rejected |
|
|
308
|
+
// timer_horizon_exceeded | payload_too_large | misaligned | db_error.
|
|
309
|
+
if (result.reason) error.reason = result.reason
|
|
310
|
+
error.result = result
|
|
311
|
+
throw error
|
|
123
312
|
}
|
|
124
313
|
|
|
125
314
|
logger.log('TransactionBuilder.commit', { status: 'success' })
|
|
@@ -144,9 +144,11 @@ export class ConsumerManager {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
try {
|
|
147
|
-
// Pop messages with affinity key for consistent routing
|
|
147
|
+
// Pop messages with affinity key for consistent routing. wait=true is
|
|
148
|
+
// a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
|
|
149
|
+
// instead of giving up after the bounded push-like attempt budget.
|
|
148
150
|
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
149
|
-
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey)
|
|
151
|
+
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
|
|
150
152
|
|
|
151
153
|
// Handle empty response
|
|
152
154
|
if (!result || !result.messages || result.messages.length === 0) {
|
|
@@ -188,9 +190,18 @@ export class ConsumerManager {
|
|
|
188
190
|
for (const message of messages) {
|
|
189
191
|
if (signal && signal.aborted) break
|
|
190
192
|
|
|
191
|
-
await this.#processMessage(message, handler, autoAck, group)
|
|
193
|
+
const ok = await this.#processMessage(message, handler, autoAck, group)
|
|
192
194
|
processedCount++
|
|
193
195
|
|
|
196
|
+
// A nack releases the lease and clamps the server cursor at the
|
|
197
|
+
// failed message: everything after it in this popped batch WILL
|
|
198
|
+
// be redelivered. Processing it now would only produce duplicates
|
|
199
|
+
// and rejected acks — abandon the rest of the batch.
|
|
200
|
+
if (autoAck && !ok) {
|
|
201
|
+
logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
|
|
194
205
|
if (limit && processedCount >= limit) break
|
|
195
206
|
}
|
|
196
207
|
} else {
|
|
@@ -216,6 +227,20 @@ export class ConsumerManager {
|
|
|
216
227
|
continue // Retry on timeout
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
// 429 (rate limited): HttpClient already retries this internally
|
|
231
|
+
// with backoff (unbounded for wait=true pop, per retry429 policy) --
|
|
232
|
+
// this branch is a defensive fallback for the case where an explicit
|
|
233
|
+
// retry429.maxAttempts override got exhausted. Back off and keep
|
|
234
|
+
// polling instead of hot-looping or rethrowing/dying.
|
|
235
|
+
if (error.status === 429) {
|
|
236
|
+
const retryAfterMs = typeof error.retryAfterSeconds === 'number' && error.retryAfterSeconds >= 0
|
|
237
|
+
? error.retryAfterSeconds * 1000
|
|
238
|
+
: 1000
|
|
239
|
+
logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, retryAfterMs))
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
219
244
|
// Check if network error
|
|
220
245
|
const isNetworkError = error.message?.includes('fetch failed') ||
|
|
221
246
|
error.message?.includes('ECONNREFUSED') ||
|
|
@@ -228,8 +253,18 @@ export class ConsumerManager {
|
|
|
228
253
|
continue
|
|
229
254
|
}
|
|
230
255
|
|
|
256
|
+
// 403 (forbidden): terminal. cluster_suspended in particular can
|
|
257
|
+
// never resolve itself, and none of the other proxy codes
|
|
258
|
+
// (storage_quota_exceeded / feature_gated / forbidden) are worth
|
|
259
|
+
// hot-looping either -- stop this worker and surface the error
|
|
260
|
+
// (with .code) to the caller instead of retrying.
|
|
261
|
+
if (error.status === 403) {
|
|
262
|
+
logger.error('ConsumerManager.worker', { workerId, status: 'forbidden', code: error.code, error: error.message })
|
|
263
|
+
throw error
|
|
264
|
+
}
|
|
265
|
+
|
|
231
266
|
// Other errors - rethrow
|
|
232
|
-
logger.error('ConsumerManager.worker', { workerId, error: error.message })
|
|
267
|
+
logger.error('ConsumerManager.worker', { workerId, error: error.message, code: error.code })
|
|
233
268
|
throw error
|
|
234
269
|
}
|
|
235
270
|
}
|
|
@@ -237,6 +272,11 @@ export class ConsumerManager {
|
|
|
237
272
|
logger.log('ConsumerManager.worker', { workerId, status: 'stopped', processedCount })
|
|
238
273
|
}
|
|
239
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Returns true when the message was handled (and acked) successfully,
|
|
277
|
+
* false when it was nacked — the caller must abandon the rest of the
|
|
278
|
+
* popped batch (the nack released the lease server-side).
|
|
279
|
+
*/
|
|
240
280
|
async #processMessage(message, handler, autoAck, group) {
|
|
241
281
|
try {
|
|
242
282
|
await handler(message)
|
|
@@ -244,18 +284,26 @@ export class ConsumerManager {
|
|
|
244
284
|
// Auto-ack on success if enabled
|
|
245
285
|
if (autoAck) {
|
|
246
286
|
const context = group ? { group } : {}
|
|
247
|
-
await this.#queen.ack(message, true, context)
|
|
248
|
-
|
|
287
|
+
const res = await this.#queen.ack(message, true, context)
|
|
288
|
+
if (res && res.success === false) {
|
|
289
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
|
|
290
|
+
} else {
|
|
291
|
+
logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
|
|
292
|
+
}
|
|
249
293
|
}
|
|
294
|
+
return true
|
|
250
295
|
} catch (error) {
|
|
251
296
|
// Auto-nack on error if enabled
|
|
252
297
|
if (autoAck) {
|
|
253
|
-
const context = group ? { group } : {}
|
|
254
|
-
await this.#queen.ack(message, false, context)
|
|
298
|
+
const context = group ? { group, error: error.message } : { error: error.message }
|
|
299
|
+
const res = await this.#queen.ack(message, false, context)
|
|
300
|
+
if (res && res.success === false) {
|
|
301
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'nack-rejected', error: res.error })
|
|
302
|
+
}
|
|
255
303
|
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
|
|
256
304
|
// Don't rethrow when autoAck is enabled - NACK was already sent
|
|
257
305
|
// This allows the consumer to continue and retry
|
|
258
|
-
return
|
|
306
|
+
return false
|
|
259
307
|
}
|
|
260
308
|
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
|
|
261
309
|
throw error
|
|
@@ -269,13 +317,17 @@ export class ConsumerManager {
|
|
|
269
317
|
// Auto-ack on success if enabled
|
|
270
318
|
if (autoAck) {
|
|
271
319
|
const context = group ? { group } : {}
|
|
272
|
-
await this.#queen.ack(messages, true, context)
|
|
273
|
-
|
|
320
|
+
const res = await this.#queen.ack(messages, true, context)
|
|
321
|
+
if (res && res.success === false) {
|
|
322
|
+
logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
|
|
323
|
+
} else {
|
|
324
|
+
logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
|
|
325
|
+
}
|
|
274
326
|
}
|
|
275
327
|
} catch (error) {
|
|
276
328
|
// Auto-nack on error if enabled
|
|
277
329
|
if (autoAck) {
|
|
278
|
-
const context = group ? { group } : {}
|
|
330
|
+
const context = group ? { group, error: error.message } : { error: error.message }
|
|
279
331
|
await this.#queen.ack(messages, false, context)
|
|
280
332
|
logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
|
|
281
333
|
// Don't rethrow when autoAck is enabled - NACK was already sent
|