queen-mq 0.16.0 → 1.0.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.
@@ -4,6 +4,80 @@
4
4
 
5
5
  import * as logger from '../utils/logger.js'
6
6
 
7
+ // --------------------------------------------------------------------------
8
+ // Host-routed proxy support (queen_proxy selects the tenant cluster from the
9
+ // first DNS label of the Host header -- proxy/src/cache.rs
10
+ // slug_from_host).
11
+ //
12
+ // `fetch()` refuses to send a caller-supplied Host header: `host` is on the
13
+ // WHATWG forbidden-header list, so undici drops it *silently*. Sending the
14
+ // wrong Host is not a cosmetic problem at a proxy -- it either 421s
15
+ // (cluster_unknown) or, on a cell with a default cluster, lands the traffic on
16
+ // somebody else's cluster. So the Host is never taken from `headers`; it is a
17
+ // first-class option (`hostHeader`) that rewrites the request URL's authority
18
+ // while the connection stays pinned to the configured address (the same thing
19
+ // `curl --resolve` does), and a Host found in `headers` is mapped onto it with
20
+ // a loud warning rather than being ignored.
21
+ // --------------------------------------------------------------------------
22
+
23
+ // Warn once per distinct offending value: a second cluster misconfigured the
24
+ // same way must not be masked by the first. Bounded so a pathological caller
25
+ // can't grow it without limit (past the cap every occurrence warns, which is
26
+ // the safe direction).
27
+ const WARNED_HOST_TRAPS = new Set()
28
+ const WARNED_HOST_TRAPS_CAP = 64
29
+
30
+ function warnHostTrap(key, message) {
31
+ logger.warn('HttpClient.hostHeader', message)
32
+ if (WARNED_HOST_TRAPS.has(key)) return
33
+ if (WARNED_HOST_TRAPS.size < WARNED_HOST_TRAPS_CAP) WARNED_HOST_TRAPS.add(key)
34
+ // Deliberately not gated behind QUEEN_CLIENT_LOG: a request that reaches the
35
+ // wrong tenant must never be quiet.
36
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
37
+ console.warn(`[queen-mq] ${message}`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Validate/parse a `hostHeader` value into the pieces we need.
43
+ * Accepts a bare authority: 'acme', 'acme.eu1.queenmq.cloud', 'acme.local:6711',
44
+ * '[::1]:6711'. Rejects anything URL-shaped so a mistake fails at construction
45
+ * instead of routing somewhere unexpected at runtime.
46
+ */
47
+ function parseHostOverride(value) {
48
+ if (typeof value !== 'string' || value.trim() === '') {
49
+ throw new Error("hostHeader must be a non-empty string, e.g. hostHeader: 'acme.eu1.queenmq.cloud'")
50
+ }
51
+ const authority = value.trim()
52
+ if (authority.includes('://') || /[\s/\\?#@]/.test(authority)) {
53
+ throw new Error(`hostHeader must be a bare authority ('acme.eu1.queenmq.cloud' or 'acme.local:6711'), not a URL — got '${authority}'`)
54
+ }
55
+ let parsed
56
+ try {
57
+ parsed = new URL(`http://${authority}`)
58
+ } catch {
59
+ throw new Error(`hostHeader is not a valid host[:port] — got '${authority}'`)
60
+ }
61
+ if (!parsed.hostname || parsed.pathname !== '/' || parsed.search || parsed.hash) {
62
+ throw new Error(`hostHeader is not a valid host[:port] — got '${authority}'`)
63
+ }
64
+ return { authority, hostname: parsed.hostname, port: parsed.port }
65
+ }
66
+
67
+ /** Split a user headers object into { hostValue, headers-without-Host }. */
68
+ function splitHostHeader(headers) {
69
+ const rest = {}
70
+ let hostValue = null
71
+ for (const [key, value] of Object.entries(headers || {})) {
72
+ if (key.toLowerCase() === 'host') {
73
+ hostValue = typeof value === 'string' ? value.trim() : String(value)
74
+ continue
75
+ }
76
+ rest[key] = value
77
+ }
78
+ return { hostValue: hostValue || null, headers: rest }
79
+ }
80
+
7
81
  export class HttpClient {
8
82
  #baseUrl
9
83
  #loadBalancer
@@ -13,6 +87,20 @@ export class HttpClient {
13
87
  #enableFailover
14
88
  #bearerToken
15
89
  #headers
90
+ #retry429
91
+ // Per-client undici Agent (Node only). Owning our own dispatcher is what
92
+ // makes Queen.close() deterministic: global-fetch keep-alive sockets belong
93
+ // to the process-wide dispatcher and can't be released per client, which
94
+ // left the event loop pinned after close(). `undefined` = not yet resolved,
95
+ // `null` = unavailable (browser / import failed) -> fall back to global fetch.
96
+ #dispatcher = undefined
97
+ #destroyed = false
98
+ // Host-routed proxy support: parsed `hostHeader` ({authority, hostname, port})
99
+ // or null, plus one connection-pinned Agent per real backend (`host:port` ->
100
+ // Agent) so load balancing still reaches each backend while every request
101
+ // carries the same virtual Host.
102
+ #hostOverride = null
103
+ #pinnedDispatchers = new Map()
16
104
 
17
105
  constructor(options = {}) {
18
106
  const {
@@ -23,7 +111,24 @@ export class HttpClient {
23
111
  retryDelayMillis = 1000,
24
112
  enableFailover = true,
25
113
  bearerToken = null,
26
- headers = {}
114
+ headers = {},
115
+ // Host to advertise, independent of the address we connect to. A
116
+ // queen_proxy deployment resolves the tenant cluster from the Host
117
+ // header's first DNS label, so this is what selects a cluster when the
118
+ // base URL points at a shared address (an IP, a cell endpoint, a test
119
+ // rig). The connection still goes to baseUrl/loadBalancer; only the
120
+ // request's authority (and TLS SNI) is rewritten. Normally unnecessary
121
+ // in production, where each cluster has its own subdomain and the base
122
+ // URL already carries the right Host.
123
+ hostHeader = null,
124
+ // 429 (rate-limited) backoff policy, separate from the 5xx/network
125
+ // retryAttempts above. { maxAttempts, baseMs, capMs }, all optional.
126
+ // maxAttempts defaults to 10 for ordinary requests; long-poll pop
127
+ // calls (marked via the `retryKind: 'pop'` request option) default to
128
+ // unbounded (retry forever, paced by backoff) unless maxAttempts is
129
+ // explicitly set here, in which case it applies to both.
130
+ // See PLAN_QUEEN_PROXY_CLOUD.md §4/§9 (client 429 backoff, blocker B4).
131
+ retry429 = {}
27
132
  } = options
28
133
 
29
134
  this.#baseUrl = baseUrl
@@ -33,23 +138,195 @@ export class HttpClient {
33
138
  this.#retryDelayMillis = retryDelayMillis
34
139
  this.#enableFailover = enableFailover
35
140
  this.#bearerToken = bearerToken
36
- this.#headers = headers || {}
37
-
38
- logger.log('HttpClient.constructor', {
39
- hasLoadBalancer: !!loadBalancer,
40
- baseUrl: baseUrl || 'load-balanced',
41
- timeoutMillis,
141
+ this.#retry429 = retry429 || {}
142
+
143
+ // A Host in `headers` can never go out over fetch(); rather than let it be
144
+ // dropped, map it onto the supported mechanism and say so out loud.
145
+ const { hostValue, headers: plainHeaders } = splitHostHeader(headers)
146
+ this.#headers = plainHeaders
147
+ let effectiveHost = hostHeader
148
+ if (hostValue) {
149
+ if (!effectiveHost) {
150
+ effectiveHost = hostValue
151
+ warnHostTrap(`map:${hostValue}`, `headers: { Host: '${hostValue}' } cannot be sent by fetch() (Host is a forbidden header name); it has been mapped onto hostHeader: '${hostValue}'. Set hostHeader directly to silence this warning.`)
152
+ } else if (String(effectiveHost).trim().toLowerCase() !== hostValue.toLowerCase()) {
153
+ warnHostTrap(`conflict:${hostValue}=>${effectiveHost}`, `headers: { Host: '${hostValue}' } is ignored because hostHeader: '${effectiveHost}' is also configured — requests will carry Host: '${effectiveHost}'.`)
154
+ }
155
+ }
156
+ this.#hostOverride = effectiveHost ? parseHostOverride(effectiveHost) : null
157
+
158
+ logger.log('HttpClient.constructor', {
159
+ hasLoadBalancer: !!loadBalancer,
160
+ baseUrl: baseUrl || 'load-balanced',
161
+ timeoutMillis,
42
162
  retryAttempts,
43
163
  enableFailover,
44
164
  hasAuth: !!bearerToken,
45
- customHeaders: Object.keys(this.#headers).length
165
+ customHeaders: Object.keys(this.#headers).length,
166
+ hostHeader: this.#hostOverride ? this.#hostOverride.authority : null,
167
+ retry429: this.#retry429
46
168
  })
47
169
  }
48
170
 
171
+ /**
172
+ * Resolve the effective 429 retry policy for a request kind.
173
+ * - 'pop': long-poll pop (wait=true). Unbounded attempts by default (the
174
+ * long-poll loop is meant to keep waiting through transient rate
175
+ * limiting), unless retry429.maxAttempts was explicitly configured.
176
+ * - anything else (push, admin calls, non-waiting pop, ...): bounded,
177
+ * defaults to 10 attempts.
178
+ * baseMs/capMs always default to 500/30000 and apply to both kinds.
179
+ */
180
+ #retry429PolicyFor(retryKind) {
181
+ const cfg = this.#retry429 || {}
182
+ const baseMs = cfg.baseMs ?? 500
183
+ const capMs = cfg.capMs ?? 30000
184
+ const maxAttempts = cfg.maxAttempts != null
185
+ ? cfg.maxAttempts
186
+ : (retryKind === 'pop' ? Infinity : 10)
187
+ return { maxAttempts, baseMs, capMs }
188
+ }
189
+
190
+ /**
191
+ * Delay before the next 429 retry attempt (milliseconds).
192
+ * Honors Retry-After (seconds) when the server sent one, with ±20%
193
+ * jitter to avoid a synchronized thundering herd; otherwise falls back to
194
+ * exponential backoff (baseMs * 2^attempt, capped at capMs), also
195
+ * jittered ±20% for the same reason.
196
+ */
197
+ #computeRetry429DelayMs(attemptIndex, retryAfterSeconds, baseMs, capMs) {
198
+ const hasRetryAfter = typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
199
+ const baseDelay = hasRetryAfter
200
+ ? retryAfterSeconds * 1000
201
+ : Math.min(capMs, baseMs * Math.pow(2, attemptIndex))
202
+ const jitterMultiplier = 1 + (Math.random() * 0.4 - 0.2) // ±20%
203
+ return Math.max(0, Math.round(baseDelay * jitterMultiplier))
204
+ }
205
+
206
+ /**
207
+ * Run a single logical request against one URL, transparently retrying
208
+ * HTTP 429 responses with backoff until the policy for `retryKind` is
209
+ * exhausted (or never, for unbounded pop policies). Any other error
210
+ * (2xx-and-parse success aside) is thrown straight through — 429 is the
211
+ * only status this layer treats as retryable; 5xx/network retry and
212
+ * cross-backend failover are handled by the caller.
213
+ */
214
+ async #executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind) {
215
+ const { maxAttempts, baseMs, capMs } = this.#retry429PolicyFor(retryKind)
216
+ let tries = 0
217
+ // eslint-disable-next-line no-constant-condition
218
+ while (true) {
219
+ tries++
220
+ try {
221
+ return await this.#executeRequest(url, method, body, requestTimeoutMillis)
222
+ } catch (error) {
223
+ if (error.status !== 429) throw error
224
+
225
+ if (tries >= maxAttempts) {
226
+ logger.error('HttpClient.retry429', { method, url, error: 'max 429 attempts exhausted', attempts: tries, code: error.code })
227
+ throw error
228
+ }
229
+
230
+ const delay = this.#computeRetry429DelayMs(tries - 1, error.retryAfterSeconds, baseMs, capMs)
231
+ logger.warn('HttpClient.retry429', { method, url, attempt: tries, retryKind: retryKind || 'default', nextDelayMs: delay, retryAfterSeconds: error.retryAfterSeconds ?? null, code: error.code ?? null })
232
+ await new Promise(resolve => setTimeout(resolve, delay))
233
+ }
234
+ }
235
+ }
236
+
237
+ // Resolve the per-client dispatcher once: undici Agent on Node, null in
238
+ // browsers (global fetch used as before). undici is a regular dependency,
239
+ // but the guarded dynamic import keeps browser bundles working.
240
+ async #getDispatcher() {
241
+ if (this.#dispatcher !== undefined) return this.#dispatcher
242
+ try {
243
+ const { Agent } = await import('undici')
244
+ this.#dispatcher = new Agent({ keepAliveTimeout: 4000, keepAliveMaxTimeout: 30000 })
245
+ } catch {
246
+ this.#dispatcher = null
247
+ }
248
+ return this.#dispatcher
249
+ }
250
+
251
+ /**
252
+ * Agent whose connector always dials `realHostname:realPort`, whatever
253
+ * authority the request URL carries — so the URL (and therefore the Host
254
+ * header, and TLS SNI) can name the tenant cluster while the socket still
255
+ * goes to the configured address. One Agent per real backend keeps load
256
+ * balancing and failover intact. Returns null when undici is unavailable
257
+ * (browser) or the client was destroyed; the caller must then refuse to
258
+ * send rather than fall back to a wrong Host.
259
+ */
260
+ #getPinnedDispatcher(realHostname, realPort) {
261
+ const key = `${realHostname}:${realPort}`
262
+ const existing = this.#pinnedDispatchers.get(key)
263
+ if (existing) return existing
264
+ if (this.#destroyed) return Promise.resolve(null)
265
+ // Cache the in-flight promise, not the resolved Agent: concurrent first
266
+ // requests to the same backend must share one Agent, or the extra ones
267
+ // would hold keep-alive sockets that destroy() never sees.
268
+ const pending = this.#createPinnedDispatcher(realHostname, realPort)
269
+ this.#pinnedDispatchers.set(key, pending)
270
+ return pending
271
+ }
272
+
273
+ async #createPinnedDispatcher(realHostname, realPort) {
274
+ let undici
275
+ try {
276
+ undici = await import('undici')
277
+ } catch {
278
+ return null
279
+ }
280
+
281
+ const { Agent, buildConnector } = undici
282
+ if (typeof buildConnector !== 'function' || typeof Agent !== 'function') return null
283
+
284
+ const connector = buildConnector({})
285
+ const servername = this.#hostOverride.hostname
286
+ const connect = (opts, callback) => connector({
287
+ ...opts,
288
+ hostname: realHostname,
289
+ host: realHostname,
290
+ port: realPort,
291
+ // SNI/cert validation follow the advertised host, not the dialed
292
+ // address — same contract as `curl --resolve`.
293
+ servername: opts.servername || servername
294
+ }, callback)
295
+
296
+ const agent = new Agent({ keepAliveTimeout: 4000, keepAliveMaxTimeout: 30000, connect })
297
+ // destroy() may have run while the import was in flight; don't leave an
298
+ // Agent behind that nobody will close.
299
+ if (this.#destroyed) {
300
+ try { await agent.destroy() } catch { /* nothing to release */ }
301
+ return null
302
+ }
303
+ return agent
304
+ }
305
+
306
+ /**
307
+ * Split a real backend URL into the URL we put on the wire (authority
308
+ * replaced by the configured hostHeader, so fetch emits it as Host) and the
309
+ * address the socket must actually dial.
310
+ */
311
+ #applyHostOverride(url) {
312
+ const real = new URL(url)
313
+ const virtual = new URL(url)
314
+ virtual.hostname = this.#hostOverride.hostname
315
+ virtual.port = this.#hostOverride.port
316
+ return {
317
+ requestUrl: virtual.toString(),
318
+ // undici's own connector receives an already-unbracketed IPv6 literal;
319
+ // we bypass that step, so strip the brackets here.
320
+ realHostname: real.hostname.replace(/^\[(.*)\]$/, '$1'),
321
+ realPort: real.port || (real.protocol === 'https:' ? '443' : '80'),
322
+ realAuthority: real.host
323
+ }
324
+ }
325
+
49
326
  async #executeRequest(url, method, body = null, requestTimeoutMillis = null) {
50
327
  const effectiveTimeout = requestTimeoutMillis || this.#timeoutMillis
51
- logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout })
52
-
328
+ logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout, host: this.#hostOverride ? this.#hostOverride.authority : undefined })
329
+
53
330
  const controller = new AbortController()
54
331
  const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout)
55
332
 
@@ -66,12 +343,30 @@ export class HttpClient {
66
343
  headers
67
344
  }
68
345
 
346
+ let requestUrl = url
347
+ if (this.#hostOverride) {
348
+ const target = this.#applyHostOverride(url)
349
+ const dispatcher = await this.#getPinnedDispatcher(target.realHostname, target.realPort)
350
+ if (!dispatcher) {
351
+ // Sending anyway would advertise Host: <address> and, at a proxy
352
+ // with a default cluster, silently write to the wrong tenant.
353
+ throw new Error(`hostHeader '${this.#hostOverride.authority}' cannot be honored here: no undici dispatcher available (browser environment, or this client was destroyed). Refusing to send with Host: ${target.realAuthority}, which would reach the wrong cluster.`)
354
+ }
355
+ options.dispatcher = dispatcher
356
+ requestUrl = target.requestUrl
357
+ } else {
358
+ const dispatcher = await this.#getDispatcher()
359
+ if (dispatcher) {
360
+ options.dispatcher = dispatcher
361
+ }
362
+ }
363
+
69
364
  if (body) {
70
365
  options.body = JSON.stringify(body)
71
366
  }
72
367
 
73
- const response = await fetch(url, options)
74
-
368
+ const response = await fetch(requestUrl, options)
369
+
75
370
  logger.log('HttpClient.response', { method, url, status: response.status })
76
371
 
77
372
  // Handle 204 No Content
@@ -84,17 +379,29 @@ export class HttpClient {
84
379
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
85
380
  error.status = response.status
86
381
 
382
+ // Proxy error contract: 429 { error, code: 'rate_limited' | 'quota_exceeded' }
383
+ // with Retry-After (seconds); 403 { error, code: 'cluster_suspended' |
384
+ // 'storage_quota_exceeded' | 'feature_gated' | 'forbidden' }. `.code`
385
+ // is attached whenever the body has one so callers can branch without
386
+ // string-matching `.message`.
387
+ if (response.status === 429) {
388
+ const retryAfterHeader = response.headers.get('retry-after')
389
+ const retryAfterSeconds = retryAfterHeader != null ? Number(retryAfterHeader) : NaN
390
+ error.retryAfterSeconds = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null
391
+ }
392
+
87
393
  try {
88
394
  const text = await response.text()
89
395
  if (text) {
90
396
  const body = JSON.parse(text)
91
397
  error.message = body.error || error.message
398
+ if (body.code) error.code = body.code
92
399
  }
93
400
  } catch (e) {
94
401
  // Ignore JSON parse errors
95
402
  }
96
403
 
97
- logger.error('HttpClient.request', { method, url, status: error.status, error: error.message })
404
+ logger.error('HttpClient.request', { method, url, status: error.status, error: error.message, code: error.code })
98
405
  throw error
99
406
  }
100
407
 
@@ -124,6 +431,13 @@ export class HttpClient {
124
431
  logger.error('HttpClient.request', { method, url, error: 'timeout', timeout: effectiveTimeout })
125
432
  throw timeoutError
126
433
  }
434
+ // undici reports every network-level failure as a bare TypeError "fetch
435
+ // failed" with the real reason (ECONNREFUSED, ECONNRESET, socket hang up,
436
+ // ...) hidden in error.cause — surface it so failures are diagnosable.
437
+ if (error.cause && error.message === 'fetch failed') {
438
+ const cause = error.cause.code || error.cause.message || String(error.cause)
439
+ error.message = `fetch failed (${cause})`
440
+ }
127
441
  logger.error('HttpClient.request', { method, url, error: error.message })
128
442
  throw error
129
443
  } finally {
@@ -131,13 +445,13 @@ export class HttpClient {
131
445
  }
132
446
  }
133
447
 
134
- async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null) {
448
+ async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null, retryKind = null) {
135
449
  let lastError = null
136
450
 
137
451
  for (let attempt = 0; attempt < this.#retryAttempts; attempt++) {
138
452
  try {
139
453
  const url = this.#getUrl() + path
140
- return await this.#executeRequest(url, method, body, requestTimeoutMillis)
454
+ return await this.#executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind)
141
455
  } catch (error) {
142
456
  lastError = error
143
457
 
@@ -159,9 +473,9 @@ export class HttpClient {
159
473
  throw lastError
160
474
  }
161
475
 
162
- async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null) {
476
+ async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
163
477
  if (!this.#loadBalancer || !this.#enableFailover) {
164
- return this.#requestWithRetry(method, path, body, requestTimeoutMillis)
478
+ return this.#requestWithRetry(method, path, body, requestTimeoutMillis, retryKind)
165
479
  }
166
480
 
167
481
  const urls = this.#loadBalancer.getAllUrls()
@@ -181,23 +495,27 @@ export class HttpClient {
181
495
  attemptedUrls.add(url)
182
496
 
183
497
  try {
184
- const result = await this.#executeRequest(url + path, method, body, requestTimeoutMillis)
185
-
498
+ // 429s are retried in place (same backend, backoff-paced) inside
499
+ // #executeWithRetry429 -- they are not a backend-health signal, so
500
+ // they must not trigger failover to a different server.
501
+ const result = await this.#executeWithRetry429(url + path, method, body, requestTimeoutMillis, retryKind)
502
+
186
503
  // Mark backend as healthy on success
187
504
  this.#loadBalancer.markHealthy(url)
188
-
505
+
189
506
  return result
190
507
  } catch (error) {
191
508
  lastError = error
192
-
509
+
193
510
  // Mark backend as unhealthy on failure (5xx or network errors)
194
511
  if (!error.status || error.status >= 500) {
195
512
  this.#loadBalancer.markUnhealthy(url)
196
513
  }
197
-
198
- logger.warn('HttpClient.failover', { url, method, path, error: error.message })
199
514
 
200
- // Don't retry on client errors (4xx)
515
+ logger.warn('HttpClient.failover', { url, method, path, error: error.message, code: error.code })
516
+
517
+ // Don't retry on client errors (4xx) -- includes a 429 whose retry429
518
+ // policy has already been exhausted by #executeWithRetry429 above.
201
519
  if (error.status && error.status >= 400 && error.status < 500) {
202
520
  throw error
203
521
  }
@@ -217,24 +535,56 @@ export class HttpClient {
217
535
  return this.#baseUrl
218
536
  }
219
537
 
220
- async get(path, requestTimeoutMillis = null, affinityKey = null) {
221
- return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey)
538
+ // `retryKind`: pass 'pop' for long-poll (wait=true) pop requests to get the
539
+ // unbounded-with-backoff 429 policy; omit for everything else (push, admin
540
+ // calls, non-waiting pop), which get the bounded default (10 attempts).
541
+ async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
542
+ return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind)
222
543
  }
223
544
 
224
- async post(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
225
- return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey)
545
+ async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
546
+ return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind)
226
547
  }
227
548
 
228
- async put(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
229
- return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis, affinityKey)
549
+ async put(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
550
+ return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis, affinityKey, retryKind)
230
551
  }
231
552
 
232
- async delete(path, requestTimeoutMillis = null, affinityKey = null) {
233
- return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis, affinityKey)
553
+ async delete(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
554
+ return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis, affinityKey, retryKind)
234
555
  }
235
556
 
236
557
  getLoadBalancer() {
237
558
  return this.#loadBalancer
238
559
  }
560
+
561
+ /**
562
+ * Release the client's HTTP resources: force-close the per-client agent's
563
+ * keep-alive sockets so the Node event loop can drain. Idempotent; requests
564
+ * issued after destroy() fall back to the global fetch dispatcher.
565
+ */
566
+ async destroy() {
567
+ if (this.#destroyed) return
568
+ this.#destroyed = true
569
+ const dispatcher = this.#dispatcher
570
+ // Subsequent (stray) requests use global fetch instead of a dead agent.
571
+ // With a hostHeader configured there is no such fallback (global fetch
572
+ // could not carry the Host) — those requests fail loudly instead.
573
+ this.#dispatcher = null
574
+ const pending = [...this.#pinnedDispatchers.values()]
575
+ this.#pinnedDispatchers.clear()
576
+ // Entries are promises (see #getPinnedDispatcher); settle them so an Agent
577
+ // created concurrently with close() is released too.
578
+ const pinned = await Promise.all(pending.map(p => Promise.resolve(p).catch(() => null)))
579
+ for (const agent of [dispatcher, ...pinned]) {
580
+ if (!agent) continue
581
+ try {
582
+ await agent.destroy()
583
+ logger.log('HttpClient.destroy', 'Agent destroyed')
584
+ } catch (error) {
585
+ logger.warn('HttpClient.destroy', { error: error.message })
586
+ }
587
+ }
588
+ }
239
589
  }
240
590
 
@@ -78,6 +78,17 @@ export class Runner {
78
78
  this._loopPromise = null
79
79
  this._flushTimers = [] // setInterval handles
80
80
  this._flushInFlight = false
81
+ // Per-partition mutex between the pop/cycle loop and the idle-flush
82
+ // timer. Both paths are read(state) -> compute -> commit against the
83
+ // same (query_id, partition_id) state rows; the server's advisory
84
+ // lock only serialises the COMMITS, so an unsynchronised flush can
85
+ // read a window's acc, have the cycle emit+delete (or re-upsert) it,
86
+ // and then emit the same acc again — a duplicate emit (or, with the
87
+ // opposite interleave, drop a freshly-reduced value). Node is
88
+ // single-threaded but both paths interleave at await points, which is
89
+ // exactly where the read->compute->commit race bites. Serialising the
90
+ // two in-process paths removes the race at its source.
91
+ this._partitionMutexes = new Map() // partitionId -> tail promise
81
92
  this._recentPartitions = new Map() // partitionId -> { partitionName, touchedAt }
82
93
  this._partitionWatermarks = new Map() // partitionId -> wmMs (cache; PG is source of truth)
83
94
  this._stats = {
@@ -253,9 +264,38 @@ export class Runner {
253
264
  }
254
265
  }
255
266
 
267
+ // ------------------------------------------------- per-partition mutex
268
+
269
+ /**
270
+ * Run `fn` with the per-partition mutex held (see _partitionMutexes in
271
+ * the constructor). Implemented as a promise chain per partitionId: each
272
+ * entrant waits on the previous tail, so the cycle path and the
273
+ * idle-flush path can never interleave their read->compute->commit
274
+ * sections for the same partition. The returned promise settles like
275
+ * `fn()` (errors propagate to the caller); the stored tail never
276
+ * rejects, so a failed cycle doesn't poison the chain.
277
+ */
278
+ _withPartitionLock(partitionId, fn) {
279
+ const key = partitionId || 'unknown'
280
+ const tail = this._partitionMutexes.get(key) || Promise.resolve()
281
+ const run = tail.then(fn)
282
+ const next = run.then(() => {}, () => {})
283
+ this._partitionMutexes.set(key, next)
284
+ next.then(() => {
285
+ // GC: drop the entry once the chain drains.
286
+ if (this._partitionMutexes.get(key) === next) this._partitionMutexes.delete(key)
287
+ })
288
+ return run
289
+ }
290
+
256
291
  // ----------------------------------------------------------- cycle
257
292
 
258
293
  async _processPartitionCycle(group) {
294
+ // Mutual exclusion with the idle-flush timer (see _partitionMutexes).
295
+ return this._withPartitionLock(group.partitionId, () => this._processPartitionCycleInner(group))
296
+ }
297
+
298
+ async _processPartitionCycleInner(group) {
259
299
  const stages = this.stream.stages
260
300
  const partitionId = group.partitionId
261
301
  const partitionName = group.partitionName
@@ -787,6 +827,11 @@ export class Runner {
787
827
  }
788
828
 
789
829
  async _flushPartition(partitionId, partitionName) {
830
+ // Mutual exclusion with the pop/cycle loop (see _partitionMutexes).
831
+ return this._withPartitionLock(partitionId, () => this._flushPartitionInner(partitionId, partitionName))
832
+ }
833
+
834
+ async _flushPartitionInner(partitionId, partitionName) {
790
835
  const stages = this.stream.stages
791
836
  const window = stages.window
792
837
 
@@ -16,8 +16,27 @@ export const CLIENT_DEFAULTS = {
16
16
  healthRetryAfterMillis: 5000, // Retry unhealthy backends after 5 seconds
17
17
  bearerToken: null, // Bearer token for proxy authentication
18
18
  headers: {}, // Custom headers to include in every request
19
+ // Host to advertise on every request, independent of the address dialed.
20
+ // A queen_proxy deployment picks the tenant cluster from the Host header's
21
+ // first DNS label, so this selects a cluster when `url` points at a shared
22
+ // address (an IP, a cell endpoint, a local rig) instead of the cluster's own
23
+ // subdomain. Bare authority only: 'acme.eu1.queenmq.cloud' or 'acme:6711'.
24
+ // The connection still goes to `url`; only the request authority (and TLS
25
+ // SNI) is rewritten. In production each cluster has its own hostname, so the
26
+ // base URL usually carries the right Host and this stays null.
27
+ // NOTE: `headers: { Host }` cannot work (fetch forbids it) — it is mapped
28
+ // onto this option with a warning.
29
+ hostHeader: null,
19
30
  handleSignals: true, // Register SIGINT/SIGTERM handlers (disable when used as a library)
20
- logger: null // Custom logger instance (must implement info/warn/error)
31
+ logger: null, // Custom logger instance (must implement info/warn/error)
32
+ // Backoff policy for HTTP 429 (rate limited) responses from a queen_proxy
33
+ // deployment. Optional -- omit for the defaults below. Shape:
34
+ // { maxAttempts?: number, baseMs?: number, capMs?: number }
35
+ // maxAttempts defaults to 10 for push/admin calls; long-poll pop (wait=true)
36
+ // retries unboundedly (paced by backoff) unless maxAttempts is set here, in
37
+ // which case it applies to both. baseMs (500) / capMs (30000) size the
38
+ // exponential backoff used when the server doesn't send Retry-After.
39
+ retry429: undefined
21
40
  }
22
41
 
23
42
  export const QUEUE_DEFAULTS = {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "0.16.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Partitioned message queue on PostgreSQL — broker client + fluent streaming SDK (windows, joins, gates) in one package",
6
6
  "main": "client-v2/index.js",
7
7
  "scripts": {
8
- "test": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js && node test-v2/run.js human",
9
- "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js",
8
+ "test": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js && node test-v2/run.js human",
9
+ "test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js",
10
10
  "test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
11
11
  "test:integration": "node test-v2/run.js human",
12
12
  "test:streams": "node test-v2/run.js stream",
@@ -20,6 +20,7 @@
20
20
  "dependencies": {
21
21
  "axios": "^1.12.2",
22
22
  "pg": "^8.16.3",
23
+ "undici": "^6.21.0",
23
24
  "uuid": "^13.0.0"
24
25
  },
25
26
  "engines": {