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.
Files changed (44) hide show
  1. package/README.md +175 -0
  2. package/client-v2/Queen.js +125 -15
  3. package/client-v2/README.md +69 -0
  4. package/client-v2/builders/QueueBuilder.js +18 -20
  5. package/client-v2/builders/TimerBuilder.js +262 -0
  6. package/client-v2/builders/TransactionBuilder.js +199 -10
  7. package/client-v2/consumer/ConsumerManager.js +64 -12
  8. package/client-v2/http/HttpClient.js +382 -32
  9. package/client-v2/kv/Kv.js +432 -0
  10. package/client-v2/kv/expiry.js +148 -0
  11. package/client-v2/streams/runtime/Runner.js +45 -0
  12. package/client-v2/utils/defaults.js +20 -1
  13. package/package.json +12 -3
  14. package/test-v2/_kvtimers.js +71 -0
  15. package/test-v2/ackwindow.js +265 -0
  16. package/test-v2/auth.js +65 -149
  17. package/test-v2/docs.js +204 -0
  18. package/test-v2/http-unit/hostHeader.test.js +411 -0
  19. package/test-v2/http-unit/retry429.test.js +319 -0
  20. package/test-v2/kv-unit/_planServer.js +65 -0
  21. package/test-v2/kv-unit/kvWire.test.js +377 -0
  22. package/test-v2/kv-unit/timerWire.test.js +177 -0
  23. package/test-v2/kv-unit/txnWire.test.js +222 -0
  24. package/test-v2/kv.js +273 -0
  25. package/test-v2/load.js +37 -41
  26. package/test-v2/maintenance.js +2 -2
  27. package/test-v2/push.js +25 -35
  28. package/test-v2/run.js +67 -5
  29. package/test-v2/semantics.js +801 -0
  30. package/test-v2/stream/_helpers.js +8 -1
  31. package/test-v2/stream/combined.js +4 -3
  32. package/test-v2/stream/cron.js +1 -1
  33. package/test-v2/stream/eventTime.js +8 -5
  34. package/test-v2/stream/operators.js +5 -5
  35. package/test-v2/stream/recovery.js +4 -1
  36. package/test-v2/stream/session.js +3 -3
  37. package/test-v2/stream/sliding.js +1 -1
  38. package/test-v2/stream/throughput.js +3 -2
  39. package/test-v2/stream/tumbling.js +16 -12
  40. package/test-v2/streams-unit/ack.test.js +203 -0
  41. package/test-v2/streams-unit/e2e.test.js +4 -1
  42. package/test-v2/timers.js +209 -0
  43. package/test-v2/transaction.js +4 -1
  44. 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
- if (this.#operations.length === 0) {
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', { operationCount: this.#operations.length, requiredLeases: this.#requiredLeases.length })
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
- logger.error('TransactionBuilder.commit', { error: result.error })
122
- throw new Error(result.error || 'Transaction failed')
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
- logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
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
- logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
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