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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The KV surface (PLAN_KV_TIMERS.md §5, §8.1).
|
|
3
|
+
*
|
|
4
|
+
* Seven operations, five code paths: get, getMany, getPrefix, put,
|
|
5
|
+
* putIfAbsent (an alias that desugars to put with expect:0 inside the stored
|
|
6
|
+
* procedure), delete, incr. Plus two conveniences this client owns: `once`,
|
|
7
|
+
* which is the idempotency marker written the way people actually reach for
|
|
8
|
+
* it, and `listAll`, which walks the keyset cursor of getPrefix.
|
|
9
|
+
*
|
|
10
|
+
* EVERYTHING GOES THROUGH `POST /api/v1/kv`, INCLUDING THE SINGLE-KEY READS.
|
|
11
|
+
* The path routes (`GET|PUT|DELETE /api/v1/kv/:ns/*key`) exist and are correct,
|
|
12
|
+
* but they are sugar "for the three cases people write by hand" (§8.1). An SDK
|
|
13
|
+
* is not one of them, and the batch route buys three things a path route
|
|
14
|
+
* cannot: it is the ONLY surface that accepts `incr` and `getPrefix`, so one
|
|
15
|
+
* transport serves all seven ops; it never has to percent-encode a key into a
|
|
16
|
+
* URL; and it keeps keys out of access logs, proxy samples and tracing spans,
|
|
17
|
+
* which is the same reasoning §5.5 applies to prefixes and which does not stop
|
|
18
|
+
* being true for a key.
|
|
19
|
+
*
|
|
20
|
+
* THE STATUS-CODE RULE YOU WILL NOTICE FIRST (§8.1): the HTTP status describes
|
|
21
|
+
* the outcome of the CALL, never the verdict of the predicate. An absent key, a
|
|
22
|
+
* lost putIfAbsent race, a delete that hit nothing -- all 200, with an explicit
|
|
23
|
+
* field in the body. `applied:false` is the single most frequent outcome of
|
|
24
|
+
* this product and it is not an error.
|
|
25
|
+
*
|
|
26
|
+
* WHICH LEADS TO THE ONE TRAP THIS LANGUAGE CANNOT DEFEND AGAINST
|
|
27
|
+
* STRUCTURALLY (§10.4): **every write returns an OBJECT, and objects are
|
|
28
|
+
* always truthy**.
|
|
29
|
+
*
|
|
30
|
+
* if (await kv.delete(ns, key)) { ... } // ALWAYS TAKEN. A bug.
|
|
31
|
+
* const r = await kv.delete(ns, key)
|
|
32
|
+
* if (r.applied) { ... } // the field is the answer
|
|
33
|
+
*
|
|
34
|
+
* That holds for all five writes -- put, putIfAbsent, delete, incr, once (whose
|
|
35
|
+
* field is `won`).
|
|
36
|
+
*
|
|
37
|
+
* AND THE ONE ABOUT EXPIRY (§5.7): a key that has expired is never returned and
|
|
38
|
+
* never counts as existing, even before the sweeper has pruned it. A `put`
|
|
39
|
+
* does NOT inherit the previous TTL -- that is not expressible, because a put
|
|
40
|
+
* that silently inherited an expiry is the fastest way to make a marker
|
|
41
|
+
* immortal.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as logger from '../utils/logger.js'
|
|
45
|
+
import { resolveExpiry } from './expiry.js'
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Op builders. Shared with TransactionBuilder, which puts the very same
|
|
49
|
+
// objects in the `kv` array of a bundle -- one definition of the wire shape,
|
|
50
|
+
// so the standalone route and the transaction rider can never drift.
|
|
51
|
+
//
|
|
52
|
+
// An entry is `{ base, expiry }`: `base` is the finished op minus its expiry,
|
|
53
|
+
// `expiry` is the caller's sugar, unresolved. `materializeKvOp` resolves it,
|
|
54
|
+
// and it is called at SEND time -- immediately here, at commit() there --
|
|
55
|
+
// because `until` is an instant and freezing it when the op was queued would
|
|
56
|
+
// ship a TTL that is already stale.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function requireName(value, what) {
|
|
60
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
61
|
+
throw new Error(`kv: ${what} must be a non-empty string, got ${JSON.stringify(value)}`)
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* §5.3: an explicitly undefined or null `expect` is a CLIENT-SIDE BUG, never a
|
|
68
|
+
* silent downgrade to upsert. The caller wrote the word `expect`, so they
|
|
69
|
+
* declared the intention to fence; sending the op without it would perform the
|
|
70
|
+
* unconditional write the fence existed to prevent.
|
|
71
|
+
*/
|
|
72
|
+
function withExpect(base, opts) {
|
|
73
|
+
if ('expect' in opts) {
|
|
74
|
+
if (opts.expect === undefined || opts.expect === null) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'kv: `expect` was written but has no value. An absent expect is not an upsert — it is a bug in the ' +
|
|
77
|
+
'caller: drop the field to mean "unconditional", or pass 0 to mean "must not exist".'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
base.expect = opts.expect
|
|
81
|
+
}
|
|
82
|
+
return base
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withRequired(base, opts) {
|
|
86
|
+
if (opts.required === true) base.required = true
|
|
87
|
+
return base
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const kvOp = {
|
|
91
|
+
get(ns, key) {
|
|
92
|
+
return { base: { op: 'get', ns: requireName(ns, 'ns'), key: requireName(key, 'key') }, expiry: null }
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getMany(ns, keys) {
|
|
96
|
+
requireName(ns, 'ns')
|
|
97
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
98
|
+
throw new Error('kv: getMany needs a non-empty array of keys')
|
|
99
|
+
}
|
|
100
|
+
keys.forEach(k => requireName(k, 'key'))
|
|
101
|
+
return { base: { op: 'getMany', ns, keys: [...keys] }, expiry: null }
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
getPrefix(ns, prefix, opts = {}) {
|
|
105
|
+
requireName(ns, 'ns')
|
|
106
|
+
// A namespace is not a table to enumerate: the empty prefix is the declared
|
|
107
|
+
// boundary, not an oversight (§5.5).
|
|
108
|
+
requireName(prefix, 'prefix')
|
|
109
|
+
const base = { op: 'getPrefix', ns, prefix }
|
|
110
|
+
if (opts.limit !== undefined) base.limit = opts.limit
|
|
111
|
+
if (opts.after !== undefined && opts.after !== null) base.after = opts.after
|
|
112
|
+
if (opts.keysOnly === true) base.keysOnly = true
|
|
113
|
+
return { base, expiry: null }
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
put(ns, key, value, opts = {}) {
|
|
117
|
+
requireName(ns, 'ns')
|
|
118
|
+
requireName(key, 'key')
|
|
119
|
+
const base = { op: 'put', ns, key, value }
|
|
120
|
+
return { base: withRequired(withExpect(base, opts), opts), expiry: opts }
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
putIfAbsent(ns, key, value, opts = {}) {
|
|
124
|
+
requireName(ns, 'ns')
|
|
125
|
+
requireName(key, 'key')
|
|
126
|
+
if ('expect' in opts) {
|
|
127
|
+
throw new Error('kv: putIfAbsent IS expect:0 — passing a different expect is a contradiction the broker rejects')
|
|
128
|
+
}
|
|
129
|
+
const base = { op: 'putIfAbsent', ns, key, value }
|
|
130
|
+
return { base: withRequired(base, opts), expiry: opts }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
delete(ns, key, opts = {}) {
|
|
134
|
+
requireName(ns, 'ns')
|
|
135
|
+
requireName(key, 'key')
|
|
136
|
+
const base = { op: 'delete', ns, key }
|
|
137
|
+
return { base: withRequired(withExpect(base, opts), opts), expiry: null }
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
incr(ns, key, delta = 1, opts = {}) {
|
|
141
|
+
requireName(ns, 'ns')
|
|
142
|
+
requireName(key, 'key')
|
|
143
|
+
if ('expect' in opts) {
|
|
144
|
+
// §5.4: incr is the way OUT of CAS. A precondition on it would
|
|
145
|
+
// reintroduce the very loop it exists to remove.
|
|
146
|
+
throw new Error('kv: incr takes no expect — it is the escape from the CAS loop, not another CAS')
|
|
147
|
+
}
|
|
148
|
+
if (typeof delta !== 'number' || !Number.isFinite(delta)) {
|
|
149
|
+
throw new Error(`kv: incr needs a finite numeric delta, got ${JSON.stringify(delta)}`)
|
|
150
|
+
}
|
|
151
|
+
const base = { op: 'incr', ns, key, delta }
|
|
152
|
+
if (opts.min !== undefined) base.min = opts.min
|
|
153
|
+
if (opts.max !== undefined) base.max = opts.max
|
|
154
|
+
return { base: withRequired(base, opts), expiry: opts }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Finish one op: merge the resolved expiry into the base. */
|
|
159
|
+
export function materializeKvOp(entry, nowMs = Date.now()) {
|
|
160
|
+
if (!entry.expiry) return { ...entry.base }
|
|
161
|
+
return { ...entry.base, ...resolveExpiry(entry.expiry, nowMs) }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Response handling.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The KV routes put the CODE in `error` (§9.5's closed taxonomy) and have no
|
|
170
|
+
* separate `code` field, while the proxy's own refusals do. HttpClient maps
|
|
171
|
+
* `body.error` onto `.message`, so on these routes the message IS the code.
|
|
172
|
+
* Copying it into `.code` -- without ever overwriting a proxy-set one -- is
|
|
173
|
+
* what lets callers branch on `err.code` here exactly as they do on a proxy
|
|
174
|
+
* 403, and never on prose, which is forbidden everywhere in this codebase.
|
|
175
|
+
*/
|
|
176
|
+
function decorate(error) {
|
|
177
|
+
if (error && !error.code && typeof error.message === 'string') {
|
|
178
|
+
error.code = error.message
|
|
179
|
+
}
|
|
180
|
+
return error
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A lost `required` gate arrives as HTTP 200 with this envelope (§8.3). */
|
|
184
|
+
function isPrecondition(body) {
|
|
185
|
+
return body && typeof body === 'object' && body.ok === false && body.reason === 'kv_precondition'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render a precondition verdict as a WriteResult, so that `applied` remains
|
|
190
|
+
* THE field to read whichever way the verdict arrived. `precondition:true`
|
|
191
|
+
* says the transaction was rolled back rather than merely refused, and
|
|
192
|
+
* `kvReason` is preserved under the name `reason` the other path uses.
|
|
193
|
+
*/
|
|
194
|
+
function preconditionResult(body, op, key) {
|
|
195
|
+
return {
|
|
196
|
+
op,
|
|
197
|
+
key,
|
|
198
|
+
applied: false,
|
|
199
|
+
precondition: true,
|
|
200
|
+
reason: body.kvReason ?? null,
|
|
201
|
+
value: body.value,
|
|
202
|
+
version: body.version,
|
|
203
|
+
failedIndex: body.failedIndex
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* §5.4: past 2^53 `JSON.parse` loses precision SILENTLY, and `incr` runs on
|
|
209
|
+
* `numeric` server-side while `version` is a BIGINT. A rate limiter reading a
|
|
210
|
+
* counter that is quietly wrong is worse than one that fails, so this raises.
|
|
211
|
+
*/
|
|
212
|
+
function assertSafeNumber(value, what) {
|
|
213
|
+
if (typeof value === 'number' && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`kv: ${what} is ${value}, beyond 2^53 — JSON.parse has already lost precision on it. ` +
|
|
216
|
+
'Refusing to hand back a number that is silently wrong.'
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return value
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export class Kv {
|
|
223
|
+
#httpClient
|
|
224
|
+
|
|
225
|
+
constructor(httpClient) {
|
|
226
|
+
this.#httpClient = httpClient
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The one path to the broker. Returns either `{ results }` (index-aligned to
|
|
231
|
+
* the ops, §6.4) or `{ precondition }` for the 200-with-`ok:false` verdict.
|
|
232
|
+
*/
|
|
233
|
+
async #apply(entries) {
|
|
234
|
+
const now = Date.now()
|
|
235
|
+
const operations = entries.map(e => materializeKvOp(e, now))
|
|
236
|
+
logger.log('Kv.apply', { count: operations.length, ops: operations.map(o => o.op) })
|
|
237
|
+
|
|
238
|
+
let body
|
|
239
|
+
try {
|
|
240
|
+
body = await this.#httpClient.post('/api/v1/kv', { operations })
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logger.error('Kv.apply', { error: error.message, status: error.status, code: error.code })
|
|
243
|
+
throw decorate(error)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (isPrecondition(body)) return { precondition: body }
|
|
247
|
+
|
|
248
|
+
const results = body && Array.isArray(body.results) ? body.results : null
|
|
249
|
+
if (!results) {
|
|
250
|
+
throw new Error('kv: unexpected response envelope — expected {"results":[...]}')
|
|
251
|
+
}
|
|
252
|
+
// The stored procedure guarantees one result per op and raises rather than
|
|
253
|
+
// returning a short array (§6.4). A short one here means something between
|
|
254
|
+
// the two rewrote the answer, and attributing result i to op j is how a
|
|
255
|
+
// caller ends up trusting the wrong verdict.
|
|
256
|
+
if (results.length !== operations.length) {
|
|
257
|
+
throw new Error(`kv: got ${results.length} results for ${operations.length} operations`)
|
|
258
|
+
}
|
|
259
|
+
return { results }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async #one(entry) {
|
|
263
|
+
const { results, precondition } = await this.#apply([entry])
|
|
264
|
+
if (precondition) return { precondition }
|
|
265
|
+
return { result: results[0] }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A read result, unwrapped. Reads have no precondition to lose. */
|
|
269
|
+
async #read(entry) {
|
|
270
|
+
const { result, precondition } = await this.#one(entry)
|
|
271
|
+
if (precondition) {
|
|
272
|
+
// Unreachable by construction (`required` is a write-only escalation),
|
|
273
|
+
// and therefore exactly the kind of thing that must not silently return
|
|
274
|
+
// `undefined` if the broker's envelope ever changes shape.
|
|
275
|
+
throw new Error(`kv: ${entry.base.op} came back as a precondition verdict, which a read cannot lose`)
|
|
276
|
+
}
|
|
277
|
+
return result
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A write result, with the precondition envelope folded into the same shape. */
|
|
281
|
+
async #write(entry) {
|
|
282
|
+
const { result, precondition } = await this.#one(entry)
|
|
283
|
+
if (precondition) return preconditionResult(precondition, entry.base.op, entry.base.key)
|
|
284
|
+
if (typeof result.applied !== 'boolean') {
|
|
285
|
+
throw new Error(`kv: ${entry.base.op} result carries no \`applied\` field; refusing to guess the verdict`)
|
|
286
|
+
}
|
|
287
|
+
return result
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -------------------------------------------------------------- reads
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* One key. Returns the ROW, not the value:
|
|
294
|
+
* `{found, key, value?, version?, expiresAt?, updatedAt?}`.
|
|
295
|
+
*
|
|
296
|
+
* `found` is separate from `value` because `null` is a legal JSONB value
|
|
297
|
+
* (§5.5): `{found:true, value:null}` and `{found:false}` are different
|
|
298
|
+
* things, and an SDK that returned "the value or null" would collapse them.
|
|
299
|
+
*/
|
|
300
|
+
async get(ns, key) {
|
|
301
|
+
return this.#read(kvOp.get(ns, key))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Many keys of one namespace. Returns `{rows, missing, truncated}`.
|
|
306
|
+
*
|
|
307
|
+
* `missing` is EXPLICIT: absence is a datum, not a hole the caller computes
|
|
308
|
+
* by difference. `truncated` means the byte budget cut the page -- those
|
|
309
|
+
* keys are in neither list, because calling them absent would be a lie.
|
|
310
|
+
*/
|
|
311
|
+
async getMany(ns, keys) {
|
|
312
|
+
return this.#read(kvOp.getMany(ns, keys))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* One page of a prefix scan: `{rows, truncated, nextAfter}`.
|
|
317
|
+
*
|
|
318
|
+
* `limit` is CLAMPED by the broker (default 100, ceiling 1000) and never
|
|
319
|
+
* rejected, plus a byte ceiling on the aggregate, with `truncated` telling
|
|
320
|
+
* the truth about both. `after` is an exclusive keyset cursor, not an
|
|
321
|
+
* offset.
|
|
322
|
+
*
|
|
323
|
+
* EACH PAGE IS ITS OWN SNAPSHOT. It is not an instant of the namespace: with
|
|
324
|
+
* `after` it can miss a key inserted behind the cursor. Good for compacting
|
|
325
|
+
* state, not for an exact count.
|
|
326
|
+
*/
|
|
327
|
+
async getPrefix(ns, prefix, opts = {}) {
|
|
328
|
+
return this.#read(kvOp.getPrefix(ns, prefix, opts))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Every row under a prefix, one page at a time.
|
|
333
|
+
*
|
|
334
|
+
* An ASYNC GENERATOR, not an array: the page count is not knowable in
|
|
335
|
+
* advance, and a method that quietly buffered a namespace into memory would
|
|
336
|
+
* be the one thing this API is careful not to offer.
|
|
337
|
+
*
|
|
338
|
+
* for await (const row of kv.listAll(ns, 'quota:acme:')) { ... }
|
|
339
|
+
*
|
|
340
|
+
* It inherits getPrefix's snapshot caveat, one page at a time.
|
|
341
|
+
*/
|
|
342
|
+
async *listAll(ns, prefix, opts = {}) {
|
|
343
|
+
let after = opts.after
|
|
344
|
+
for (;;) {
|
|
345
|
+
const page = await this.getPrefix(ns, prefix, { ...opts, after })
|
|
346
|
+
for (const row of page.rows || []) yield row
|
|
347
|
+
if (!page.truncated) return
|
|
348
|
+
if (!page.nextAfter) return
|
|
349
|
+
after = page.nextAfter
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ------------------------------------------------------------- writes
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Write a value. Exactly one of `ttlSeconds` (or its sugar `ttl` / `until`)
|
|
357
|
+
* and `forever:true` is required -- the broker owns that rule so all seven
|
|
358
|
+
* clients inherit it.
|
|
359
|
+
*
|
|
360
|
+
* `expect` is the optimistic lock: `0` means "must not exist" (and wins even
|
|
361
|
+
* against an expired row not yet pruned), `N > 0` is a pure UPDATE that
|
|
362
|
+
* creates NOTHING when it matches no row. The version handed to a loser is
|
|
363
|
+
* ADVISORY -- never reuse it blindly as a fencing token.
|
|
364
|
+
*
|
|
365
|
+
* `required:true` escalates a lost precondition into a rolled-back
|
|
366
|
+
* transaction; on this route that comes back as a WriteResult with
|
|
367
|
+
* `precondition:true`, and inside a bundle it is what makes `commit()`
|
|
368
|
+
* return the verdict.
|
|
369
|
+
*
|
|
370
|
+
* Returns a WriteResult: `{applied, op, key, value, version, reason?}`.
|
|
371
|
+
* ALWAYS TRUTHY -- read `applied`.
|
|
372
|
+
*/
|
|
373
|
+
async put(ns, key, value, opts = {}) {
|
|
374
|
+
return this.#write(kvOp.put(ns, key, value, opts))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** put with `expect:0`, under the name of the thing. Same WriteResult, and the loser gets the winner's value. */
|
|
378
|
+
async putIfAbsent(ns, key, value, opts = {}) {
|
|
379
|
+
return this.#write(kvOp.putIfAbsent(ns, key, value, opts))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Delete a key, optionally fenced with `expect`.
|
|
384
|
+
*
|
|
385
|
+
* Returns a WriteResult. `if (await kv.delete(...))` is ALWAYS TRUE and is a
|
|
386
|
+
* bug: read `.applied`.
|
|
387
|
+
*/
|
|
388
|
+
async delete(ns, key, opts = {}) {
|
|
389
|
+
return this.#write(kvOp.delete(ns, key, opts))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Add `delta` to a numeric key, atomically and without a CAS loop.
|
|
394
|
+
*
|
|
395
|
+
* With `max`, **`applied` IS the admission decision**: an increment that
|
|
396
|
+
* would break the ceiling does not apply, does not saturate and does not
|
|
397
|
+
* truncate -- it comes back `applied:false, reason:'limit'` with the CURRENT
|
|
398
|
+
* value. Comparing client-side after incrementing means the request that
|
|
399
|
+
* broke the ceiling has already consumed budget.
|
|
400
|
+
*
|
|
401
|
+
* The TTL is CREATE-ONLY: a live row keeps its expiry, so a fixed-window
|
|
402
|
+
* limiter actually closes its window. An expired row counts as zero and
|
|
403
|
+
* starts a new window, which is what makes the limiter one call.
|
|
404
|
+
*/
|
|
405
|
+
async incr(ns, key, delta = 1, opts = {}) {
|
|
406
|
+
const res = await this.#write(kvOp.incr(ns, key, delta, opts))
|
|
407
|
+
assertSafeNumber(res.value, `the counter at ${key}`)
|
|
408
|
+
return res
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The idempotency marker, written the way it is actually used:
|
|
413
|
+
*
|
|
414
|
+
* const { won } = await kv.once('test-idem', orderId, { ttl: '24h' })
|
|
415
|
+
* if (!won) return // somebody already did this
|
|
416
|
+
*
|
|
417
|
+
* `putIfAbsent` with a default value of `true`. Returns `{won, value,
|
|
418
|
+
* version, result}` -- `value` being the WINNER's value, so the loser never
|
|
419
|
+
* needs a second round trip.
|
|
420
|
+
*
|
|
421
|
+
* And the sentence that has to travel with it: **putIfAbsent plus a TTL is
|
|
422
|
+
* not a distributed lock** (§5.7). A lock that expires is not revoked; the
|
|
423
|
+
* old holder keeps working, it simply no longer has the row. The defence is
|
|
424
|
+
* fencing -- carry the `version` as `expect` on every later write -- and
|
|
425
|
+
* that limits the damage rather than removing it.
|
|
426
|
+
*/
|
|
427
|
+
async once(ns, key, opts = {}) {
|
|
428
|
+
const value = opts.value !== undefined ? opts.value : true
|
|
429
|
+
const res = await this.putIfAbsent(ns, key, value, opts)
|
|
430
|
+
return { won: res.applied === true, value: res.value, version: res.version, result: res }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expiry sugar for the KV surface, and the ONE duration parser this SDK has.
|
|
3
|
+
*
|
|
4
|
+
* PLAN_KV_TIMERS.md §20.1 ratified `ttlSeconds` as the wire's unit: the server
|
|
5
|
+
* speaks seconds everywhere (`dedup_window_seconds`, `lease_seconds`,
|
|
6
|
+
* `retention_seconds`), so the product keeps ONE duration convention and the
|
|
7
|
+
* SDKs do the converting. `ttlMillis` does not exist in any client and must not
|
|
8
|
+
* be added -- it would reintroduce, through the service entrance, the double
|
|
9
|
+
* convention that decision removed.
|
|
10
|
+
*
|
|
11
|
+
* §20.6 is the other half of the rule, and it is what this file's parser
|
|
12
|
+
* serves twice: DURATIONS THAT CAN BE SUB-SECOND ARE IN MILLISECONDS, THE ONES
|
|
13
|
+
* THAT CANNOT ARE IN SECONDS. A 250 ms retry backoff is a real and central use
|
|
14
|
+
* of timers, so their wire is `delayMs`; a sub-second TTL is not a real use for
|
|
15
|
+
* anybody, so the KV wire is `ttlSeconds`. The parser below returns
|
|
16
|
+
* MILLISECONDS -- the finer of the two -- and each caller converts.
|
|
17
|
+
*
|
|
18
|
+
* NOT EXPORTED FROM THE BARREL, deliberately (§10.4). The client's stated
|
|
19
|
+
* convention is "a number with the unit in its name", and this is its first
|
|
20
|
+
* string duration. Confining the parser to this file is what keeps it from
|
|
21
|
+
* becoming a general-purpose utility that then has to accept every format
|
|
22
|
+
* anybody ever wrote.
|
|
23
|
+
*
|
|
24
|
+
* ROUNDING IS ALWAYS UP. A TTL rounded down can expire a marker BEFORE the
|
|
25
|
+
* window it was supposed to cover, which is the failure this feature exists to
|
|
26
|
+
* prevent; rounding up costs at most a second of retention.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const UNIT_MS = {
|
|
30
|
+
ms: 1,
|
|
31
|
+
s: 1000,
|
|
32
|
+
m: 60 * 1000,
|
|
33
|
+
h: 60 * 60 * 1000,
|
|
34
|
+
d: 24 * 60 * 60 * 1000
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One or more <number><unit> pairs: '30s', '1h30m', '250ms'. Anchored, so a
|
|
38
|
+
// typo is an error and never a silent partial parse ('1hour' does not become
|
|
39
|
+
// one hour).
|
|
40
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a duration STRING into milliseconds. Numbers are refused on purpose:
|
|
44
|
+
* `{ ttl: 5000 }` cannot be read as seconds or milliseconds without guessing,
|
|
45
|
+
* and guessing here would be a factor-of-1000 bug in somebody's retention.
|
|
46
|
+
* The numeric spellings are the ones with the unit in the name --
|
|
47
|
+
* `ttlSeconds` and `delayMs`.
|
|
48
|
+
*/
|
|
49
|
+
export function parseDurationMs(value) {
|
|
50
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`duration must be a string with a unit, e.g. '250ms', '30s', '15m', '24h', '7d' — got ${JSON.stringify(value)}. ` +
|
|
53
|
+
'For a plain number use ttlSeconds (KV) or delayMs (timers), which carry their unit in the name.'
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
const parts = value.trim().toLowerCase().match(/\d+(?:\.\d+)?[a-z]+/g)
|
|
57
|
+
const joined = parts ? parts.join('') : ''
|
|
58
|
+
if (!parts || joined !== value.trim().toLowerCase()) {
|
|
59
|
+
throw new Error(`invalid duration '${value}': expected <number><unit> parts, e.g. '30s' or '1h30m'`)
|
|
60
|
+
}
|
|
61
|
+
let ms = 0
|
|
62
|
+
for (const part of parts) {
|
|
63
|
+
const m = DURATION_RE.exec(part)
|
|
64
|
+
if (!m) {
|
|
65
|
+
throw new Error(`invalid duration '${value}': unknown unit in '${part}' (use ms, s, m, h, d)`)
|
|
66
|
+
}
|
|
67
|
+
ms += Number(m[1]) * UNIT_MS[m[2]]
|
|
68
|
+
}
|
|
69
|
+
if (!(ms > 0)) {
|
|
70
|
+
throw new Error(`invalid duration '${value}': must be greater than zero`)
|
|
71
|
+
}
|
|
72
|
+
return ms
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Milliseconds since the epoch for a Date, an ISO string or an epoch number. */
|
|
76
|
+
function instantMs(value) {
|
|
77
|
+
if (value instanceof Date) {
|
|
78
|
+
const t = value.getTime()
|
|
79
|
+
if (Number.isNaN(t)) throw new Error('until: invalid Date')
|
|
80
|
+
return t
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const t = Date.parse(value)
|
|
85
|
+
if (Number.isNaN(t)) throw new Error(`until: '${value}' is not a parseable date`)
|
|
86
|
+
return t
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`until must be a Date, an ISO 8601 string or an epoch-milliseconds number — got ${typeof value}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the expiry fields of one KV write into the CANONICAL wire shape.
|
|
93
|
+
*
|
|
94
|
+
* Returns `{ ttlSeconds }`, `{ forever: true }`, or `{}`.
|
|
95
|
+
*
|
|
96
|
+
* The empty answer is deliberate and is the reason this function validates so
|
|
97
|
+
* little: §5.1's rule -- exactly one of `ttlSeconds` and `forever:true`, zero
|
|
98
|
+
* or two being the same error -- lives in `kv_apply_v1`, so that all seven
|
|
99
|
+
* clients AND the embedded broker (which never passes through an HTTP handler)
|
|
100
|
+
* inherit it without a line of their own. Re-implementing it here would give
|
|
101
|
+
* the product two places that can disagree about when a key dies. What IS
|
|
102
|
+
* checked here is only what the server cannot see, because it is sugar that
|
|
103
|
+
* never reaches it: two spellings of the same field, a `forever` that is not
|
|
104
|
+
* `true`, and an `until` already in the past.
|
|
105
|
+
*
|
|
106
|
+
* `nowMs` is a parameter so the conversion happens at SEND time. In a
|
|
107
|
+
* transaction the ops are built when the caller writes them and sent at
|
|
108
|
+
* `commit()`, and an `until` frozen at build time would ship a TTL that is
|
|
109
|
+
* already stale by however long the bundle took to assemble.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveExpiry(opts = {}, nowMs = Date.now()) {
|
|
112
|
+
const spellings = ['ttlSeconds', 'ttl', 'until'].filter(k => opts[k] !== undefined)
|
|
113
|
+
if (spellings.length > 1) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`expiry declared twice (${spellings.join(' and ')}): ttlSeconds is the canonical field, ` +
|
|
116
|
+
'`ttl` is a duration string and `until` is an instant — pick one'
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const out = {}
|
|
121
|
+
|
|
122
|
+
if (opts.forever !== undefined) {
|
|
123
|
+
if (opts.forever !== true) {
|
|
124
|
+
throw new Error('forever must be exactly true; there is no forever:false — omit it and declare a TTL')
|
|
125
|
+
}
|
|
126
|
+
out.forever = true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (opts.ttlSeconds !== undefined) {
|
|
130
|
+
out.ttlSeconds = opts.ttlSeconds
|
|
131
|
+
} else if (opts.ttl !== undefined) {
|
|
132
|
+
out.ttlSeconds = Math.ceil(parseDurationMs(opts.ttl) / 1000)
|
|
133
|
+
} else if (opts.until !== undefined) {
|
|
134
|
+
const deltaMs = instantMs(opts.until) - nowMs
|
|
135
|
+
const seconds = Math.ceil(deltaMs / 1000)
|
|
136
|
+
if (seconds <= 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`until is already in the past (${Math.round(deltaMs)}ms from now): a key written with a dead ` +
|
|
139
|
+
'TTL would be refused by the broker, and a key written with none would be immortal'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
out.ttlSeconds = seconds
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Both declared: forwarded as-is on purpose, so the broker gives its own
|
|
146
|
+
// `kv_expiry_not_specified` verdict and there is one rule, in one place.
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
@@ -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
|
|
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 = {
|