queen-mq 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pop autopilot, client side.
3
+ *
4
+ * The broker owns a controller that sizes a pop from state this client cannot
5
+ * see: how many partitions of the (queue, group) are ready, how old their
6
+ * oldest ready message is, at what rate messages are arriving. Two knobs are
7
+ * under its control — `partitions` (the sweep width) and `batch` (the message
8
+ * budget for the sweep).
9
+ *
10
+ * THE RULE, and it is the only one: an explicit user value is sacred. Autopilot
11
+ * applies ONLY to the knobs the user left unset, and it applies to them one by
12
+ * one. A consumer that pins `partitions(1)` and says nothing about batch keeps
13
+ * its single-partition claim forever and lets the broker size the batch; the
14
+ * pinned dimension is never "adjusted", not even towards a value the controller
15
+ * would consider better.
16
+ *
17
+ * The wire shape follows the conflation precedent (see conflation.js): a client
18
+ * that is not engaging autopilot sends the byte-identical request it sent
19
+ * before this feature existed.
20
+ *
21
+ * autopilot=true emitted ONLY when at least one of the two knobs is
22
+ * being left to the broker. Never as autopilot=false.
23
+ * partitions / batch OMITTED for the dimensions the broker is choosing,
24
+ * sent exactly as before for the ones the user set.
25
+ *
26
+ * WHAT AN OLD BROKER DOES, and why there is no capability check here. A broker
27
+ * older than 1.2 ignores unknown query params: the request succeeds, and the
28
+ * two omitted knobs fall back to the SERVER-side defaults (batch 200,
29
+ * partitions 1) instead of the old client-side ones. That is a sizing
30
+ * difference, not a correctness one — nothing is lost, misordered or delivered
31
+ * twice — so unlike conflation (which silently hands a last-value consumer a
32
+ * whole backlog, hence CONFLATION_UNSUPPORTED) this degrades quietly and on
33
+ * purpose. Callers who need the old numbers against an old broker set them
34
+ * explicitly, or turn autopilot off.
35
+ */
36
+
37
+ /**
38
+ * The environment variable that disables pop autopilot for a whole process:
39
+ * QUEEN_SDK_POP_AUTOPILOT=off restores the client-side defaults this SDK
40
+ * applied before autopilot existed, byte for byte. It is read once, in the
41
+ * Queen constructor, so a single deployment can be rolled back without touching
42
+ * code.
43
+ *
44
+ * "off", "false", "0", "no" and "disabled" all disable it (case-insensitive,
45
+ * surrounding space ignored). Every other value, including the empty one,
46
+ * leaves autopilot on.
47
+ */
48
+ export const ENV_POP_AUTOPILOT = 'QUEEN_SDK_POP_AUTOPILOT'
49
+
50
+ const DISABLING_VALUES = new Set(['off', 'false', '0', 'no', 'disabled'])
51
+
52
+ /** Whether ENV_POP_AUTOPILOT asks for the pre-autopilot behavior. */
53
+ export function popAutopilotDisabledByEnv() {
54
+ // `process` is absent in a browser bundle; there the variable cannot be set
55
+ // and autopilot is simply on.
56
+ const raw = typeof process !== 'undefined' && process.env ? process.env[ENV_POP_AUTOPILOT] : undefined
57
+ return DISABLING_VALUES.has(String(raw ?? '').trim().toLowerCase())
58
+ }
59
+
60
+ /**
61
+ * The batch/partitions/autopilot decision for one pop — the values that travel
62
+ * and the ones that do not.
63
+ *
64
+ * IT EXISTS SO THERE IS EXACTLY ONE COPY OF THE EMISSION RULE. pop() and
65
+ * consume() build their query strings separately (QueueBuilder.pop's inline
66
+ * params vs ConsumerManager#buildParams) and the two have drifted before — the
67
+ * standing comment in QueueBuilder.js is there because of it. A rule with three
68
+ * branches and a per-dimension carve-out is precisely the kind that gets copied
69
+ * wrong, so both builders call this and then only PLACE what it returns; where
70
+ * each key sits in the query string stays with the builder, because the
71
+ * pre-autopilot key order is part of what "byte-identical" means here.
72
+ *
73
+ * @param {object} opts
74
+ * @param {number|null} opts.batch - the USER's batch. null/0/undefined means
75
+ * unset: the dimension the broker gets to choose. Neither builder may
76
+ * substitute a default before calling this.
77
+ * @param {number|null} opts.maxPartitions - the USER's sweep width, same
78
+ * convention.
79
+ * @param {number} opts.fallbackBatch - client-side default applied to an unset
80
+ * batch when autopilot is NOT engaged.
81
+ * @param {boolean} opts.autopilot - the resolved decision for this call.
82
+ * @returns {{autopilot: boolean, batch: string|null, partitions: string|null}}
83
+ * Strings are ready to append; null means "this key does not travel".
84
+ */
85
+ export function popSizing({ batch, maxPartitions, fallbackBatch, autopilot }) {
86
+ const batchSet = typeof batch === 'number' && batch > 0
87
+ const partitionsSet = typeof maxPartitions === 'number' && maxPartitions > 0
88
+
89
+ // Note the case that looks like an omission and is not: when the user set
90
+ // BOTH knobs there is nothing left for the controller to decide, so
91
+ // autopilot=true is NOT emitted and the request is byte-identical to the one
92
+ // this SDK sent before autopilot existed. Sending the flag anyway would be
93
+ // harmless on the broker and dishonest in a packet capture.
94
+ if (autopilot && !(batchSet && partitionsSet)) {
95
+ return {
96
+ autopilot: true,
97
+ batch: batchSet ? String(batch) : null,
98
+ partitions: partitionsSet ? String(maxPartitions) : null
99
+ }
100
+ }
101
+
102
+ return {
103
+ autopilot: false,
104
+ batch: String(batchSet ? batch : fallbackBatch),
105
+ // The legacy gate: partitions travels only above 1, because 1 IS the
106
+ // server-side default and a v4-era client never sent it.
107
+ partitions: partitionsSet && maxPartitions > 1 ? String(maxPartitions) : null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * What the broker chose for one pop, echoed back in the response under
113
+ * "autopilot" when the request engaged autopilot. It is additive: a broker that
114
+ * does not send it, or a pop that never asked, yields null everywhere it is
115
+ * exposed.
116
+ *
117
+ * Reading it is optional — the messages are already sized by it — but it is the
118
+ * only way to see the controller working from the client side, and the only
119
+ * input to the empty-poll pacing below.
120
+ *
121
+ * Unknown keys inside it are ignored, and an unknown-shaped value is treated as
122
+ * absent rather than as an error: this field is the broker telling the client
123
+ * what it did, and a client that refuses to run because a newer broker grew a
124
+ * fourth number would be a self-inflicted outage.
125
+ *
126
+ * @param {object|null} result - parsed pop response (null for a 204)
127
+ * @returns {{partitions: number, batch: number, waitMillis: number}|null}
128
+ */
129
+ export function parseAutopilotDecision(result) {
130
+ const raw = result && typeof result === 'object' ? result.autopilot : null
131
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
132
+
133
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
134
+ return {
135
+ /** Sweep width the broker used for this pop. */
136
+ partitions: num(raw.partitions),
137
+ /** Message budget the broker used for this pop. */
138
+ batch: num(raw.batch),
139
+ /**
140
+ * The broker's advice on how long to wait before polling again (wire name:
141
+ * waitMs). Present only when the broker has an opinion, and it is advice,
142
+ * not a lease: the consume loop honors it for the sleep it was already
143
+ * taking between empty non-waiting pops, nothing more. 0 = no advice.
144
+ */
145
+ waitMillis: num(raw.waitMs)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The sleep the consume loop has always taken between two empty pops that are
151
+ * NOT long-polling. A waiting pop already blocks on the broker, so it never
152
+ * reaches here.
153
+ */
154
+ export const EMPTY_POLL_BACKOFF_MILLIS = 100
155
+
156
+ /**
157
+ * How long to wait after an empty pop: the broker's advice when it gave one,
158
+ * the historical constant otherwise.
159
+ *
160
+ * The advice is honored as given, without a ceiling of this client's invention.
161
+ * The sleep it feeds is raced against the caller's abort signal by the loop, so
162
+ * even an absurd value cannot outlive a cancellation — which is the only
163
+ * property that has to hold locally.
164
+ */
165
+ export function emptyPollDelayMillis(decision) {
166
+ if (decision && decision.waitMillis > 0) return decision.waitMillis
167
+ return EMPTY_POLL_BACKOFF_MILLIS
168
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Conflation response contract (PLAN_CONFLATION.md §3.3, §4).
3
+ *
4
+ * Conflation — last-value delivery for a consumer group — is declared as a pop
5
+ * query parameter and answered in the pop RESPONSE. Two response keys carry the
6
+ * whole client-side contract, and both are handled here so that pop() and the
7
+ * consume loop cannot drift apart:
8
+ *
9
+ * "conflation": true the broker actually applied last-value delivery.
10
+ * Rides EVERY conflating response, empty ones
11
+ * included — a conflating pop answers 200 with a
12
+ * body instead of a bodiless 204 precisely so the
13
+ * echo has somewhere to sit.
14
+ * "conflationConflict": true this consumer declared a policy the group does
15
+ * not have. The STORED group setting wins (§3.3);
16
+ * the consumer keeps working and warns once.
17
+ *
18
+ * DEGRADE LOUDLY (§4). No SDK negotiates a version with the broker. A 1.1.0
19
+ * client against an older broker sends `conflation=true`, the broker ignores the
20
+ * unknown query parameter, and the consumer silently processes the entire
21
+ * backlog message by message — the exact silent failure the feature must not
22
+ * have. So: requested conflation + no echo => raise, on the FIRST response,
23
+ * before a single message is handled. `null` (an old broker's 204 on an empty
24
+ * pop) is the most important case to raise on, not the least: it is what an idle
25
+ * queue looks like, and it is the first thing a fresh consumer sees.
26
+ */
27
+
28
+ import * as logger from './logger.js'
29
+
30
+ /** `error.code` on the degrade-loudly error, so callers can branch without string-matching. */
31
+ export const CONFLATION_UNSUPPORTED = 'conflation_unsupported'
32
+
33
+ /** The message the plan fixes for every SDK (§4). Keep it identical across clients. */
34
+ export const CONFLATION_UNSUPPORTED_MESSAGE =
35
+ 'conflation was requested but this broker did not apply it — requires broker >= 1.1.0'
36
+
37
+ // Once per (queue, group) PER PROCESS, per §3.3: a mismatched fleet during a
38
+ // rolling deploy would otherwise emit one warning per pop, forever.
39
+ const warnedConflicts = new Set()
40
+
41
+ // A (queue, group) whose conflict the broker has ALREADY told us about. Proof
42
+ // that this broker understands the parameter, so a later response without the
43
+ // echo for that same pair is the same known conflict and not an old broker.
44
+ //
45
+ // It is belt-and-braces now rather than the load-bearing part it once was: a
46
+ // 1.1.0 broker keeps the 200 and the body whenever the answer has anything to
47
+ // say about conflation (`pop_status`), so an EMPTY conflicting pop carries
48
+ // `conflationConflict` too and the check above catches it on the first round
49
+ // trip. Keeping the memo costs nothing and still covers a first contact whose
50
+ // answer somehow arrives without either key.
51
+ const knownConflicts = new Set()
52
+
53
+ /**
54
+ * Identity of the thing a policy hangs on: the queue (or namespace/task pop
55
+ * target) and the consumer group. Deliberately NOT the affinity key, which
56
+ * includes the partition — the policy is per (queue, group).
57
+ */
58
+ export function conflationKey({ queue, namespace, task, group }) {
59
+ const target = queue || `${namespace || '*'}/${task || '*'}`
60
+ return `${target}|${group || '__QUEUE_MODE__'}`
61
+ }
62
+
63
+ /**
64
+ * Inspect one pop response on behalf of a consumer that ASKED for conflation.
65
+ * Call it for every response, including empty ones — that is where the old
66
+ * broker shows itself first.
67
+ *
68
+ * @param {object|null} result - parsed pop response (null for a 204)
69
+ * @param {object} ctx - { queue, namespace, task, group }
70
+ * @throws {Error} with `.code === CONFLATION_UNSUPPORTED` when the broker did
71
+ * not apply conflation and did not explain why.
72
+ */
73
+ export function checkConflationResponse(result, ctx) {
74
+ const key = conflationKey(ctx)
75
+
76
+ if (result && result.conflationConflict === true) {
77
+ knownConflicts.add(key)
78
+ if (!warnedConflicts.has(key)) {
79
+ warnedConflicts.add(key)
80
+ logger.warn('Conflation.conflict', {
81
+ queue: ctx.queue || null,
82
+ namespace: ctx.namespace || null,
83
+ task: ctx.task || null,
84
+ group: ctx.group || null,
85
+ message:
86
+ 'this consumer declared conflation but the consumer group is registered without it — ' +
87
+ 'the STORED group setting wins and this consumer is receiving normal batches. ' +
88
+ 'Delete/recreate the group to change its policy.'
89
+ })
90
+ }
91
+ return
92
+ }
93
+
94
+ if (result && result.conflation === true) return
95
+
96
+ // Pop maintenance: the broker refused the pop before it reached the claim
97
+ // path, so there is no policy to echo and nothing to conclude from the
98
+ // absence of one. Reading it as "old broker" would stop every conflating
99
+ // consumer in the fleet the moment an operator pauses pops.
100
+ if (result && result.paused === true) return
101
+
102
+ // The group is known to disagree with us; this response is that same
103
+ // conflict, seen through a body that could not carry the flag.
104
+ if (knownConflicts.has(key)) return
105
+
106
+ const error = new Error(CONFLATION_UNSUPPORTED_MESSAGE)
107
+ error.code = CONFLATION_UNSUPPORTED
108
+ throw error
109
+ }
110
+
111
+ /**
112
+ * Test hook: the warn-once and known-conflict registries are process-wide by
113
+ * design, which makes them sticky across tests in one runner process.
114
+ */
115
+ export function resetConflationWarnings() {
116
+ warnedConflicts.clear()
117
+ knownConflicts.clear()
118
+ }
@@ -51,9 +51,14 @@ export const QUEUE_DEFAULTS = {
51
51
  encryptionEnabled: false // No encryption by default
52
52
  }
53
53
 
54
+ // batch and maxPartitions here are the AUTOPILOT-OFF defaults. With autopilot on
55
+ // (the default) a knob the caller never set is not defaulted at all -- it is
56
+ // omitted from the pop so the broker sizes it (see utils/autopilot.js). These
57
+ // values are what comes back with QueueBuilder.autopilot(false), or with
58
+ // QUEEN_SDK_POP_AUTOPILOT=off for a whole process.
54
59
  export const CONSUME_DEFAULTS = {
55
60
  concurrency: 1, // Single worker
56
- batch: 1, // One message at a time
61
+ batch: 1, // One message at a time (autopilot off only)
57
62
  autoAck: true, // Client-side auto-ack (NOT sent to server)
58
63
  wait: true, // Long polling enabled
59
64
  timeoutMillis: 30000, // 30 seconds long poll timeout
@@ -63,11 +68,19 @@ export const CONSUME_DEFAULTS = {
63
68
  renewLeaseIntervalMillis: null, // Auto-renewal interval when enabled
64
69
  subscriptionMode: null, // No subscription mode (standard queue mode)
65
70
  subscriptionFrom: null, // No subscription start point
66
- maxPartitions: 1 // v4 multi-partition pop cap (1 = legacy single-partition)
71
+ maxPartitions: 1, // v4 multi-partition pop cap (autopilot off only)
72
+ // Last-value delivery for this consumer group (PLAN_CONFLATION §1.1): a pop
73
+ // of a partition delivers only the NEWEST visible message and commits past
74
+ // the ones it skipped. Off by default, and only ever SENT when true, so a
75
+ // client that never touches it is byte-identical on the wire. Requires
76
+ // broker >= 1.1.0 — an older one ignores the parameter, which the SDK
77
+ // detects from the missing response echo and raises on (§4).
78
+ conflation: false
67
79
  }
68
80
 
81
+ // As in CONSUME_DEFAULTS, batch is the autopilot-OFF default.
69
82
  export const POP_DEFAULTS = {
70
- batch: 1, // One message
83
+ batch: 1, // One message (autopilot off only)
71
84
  wait: false, // No long polling (immediate return)
72
85
  timeoutMillis: 30000, // 30 seconds if wait=true
73
86
  autoAck: false // Server-side auto-ack (false = manual ack required)
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "1.0.6",
3
+ "version": "1.2.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 test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.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 test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.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 test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.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 test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.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",