spice-couchbase 2.0.61 → 2.0.63
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/build/connectionHealth.js +573 -0
- package/build/connectionHealth.test.js +659 -0
- package/build/index.auto-optimize.test.js +204 -2
- package/build/index.js +341 -43
- package/build/utility.js +65 -0
- package/build/utility.test.js +49 -1
- package/package.json +3 -2
- package/src/connectionHealth.js +529 -0
- package/src/connectionHealth.test.js +412 -0
- package/src/index.auto-optimize.test.js +158 -2
- package/src/index.js +320 -55
- package/src/utility.js +63 -0
- package/src/utility.test.js +69 -0
package/src/index.js
CHANGED
|
@@ -4,6 +4,8 @@ let _ = require("lodash");
|
|
|
4
4
|
let couchbase = require("couchbase");
|
|
5
5
|
const { ConflictResolutionType } = require("couchbase");
|
|
6
6
|
const { QueryScanConsistency } = require("couchbase");
|
|
7
|
+
const { parseResourceList, extractStatementResources } = require("./utility");
|
|
8
|
+
const { createHealthMonitor, openHandles } = require("./connectionHealth");
|
|
7
9
|
|
|
8
10
|
if (!Promise.allSettled) {
|
|
9
11
|
Promise.allSettled = (promises) =>
|
|
@@ -50,6 +52,58 @@ const optimizeState = {
|
|
|
50
52
|
seen: new Set(),
|
|
51
53
|
cooldownUntil: 0,
|
|
52
54
|
};
|
|
55
|
+
// Auto cover indexing is opt-in per resource. A resource is the collection a
|
|
56
|
+
// Database instance serves (scoped deployments) or the `type = '...'` literal
|
|
57
|
+
// legacy bucket mode adds to every statement; raw statements also count their
|
|
58
|
+
// FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
|
|
59
|
+
// optimised when, in order of precedence:
|
|
60
|
+
// 1. the call says so: query_options.auto_optimize (true / false)
|
|
61
|
+
// 2. the instance says so: constructor option or connection config
|
|
62
|
+
// `auto_optimize` (true / false)
|
|
63
|
+
// 3. the application policy: Database.setAutoOptimizePolicy(fn), where
|
|
64
|
+
// fn(resource, context) returns true / false, or
|
|
65
|
+
// undefined to defer to the static lists
|
|
66
|
+
// 4. a static list names a resource it touches: comma-separated
|
|
67
|
+
// SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
|
|
68
|
+
// pre-2.0.62 behaviour), or connection config / constructor option
|
|
69
|
+
// `auto_optimize_resources`.
|
|
70
|
+
const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
|
|
71
|
+
process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
|
|
72
|
+
);
|
|
73
|
+
let autoOptimizePolicy = null;
|
|
74
|
+
|
|
75
|
+
// One health monitor per process, shared by every copy of this module that
|
|
76
|
+
// ends up loaded. See connectionHealth.js.
|
|
77
|
+
const HEALTH_MONITOR_KEY = Symbol.for("spice-couchbase.health-monitor");
|
|
78
|
+
const health =
|
|
79
|
+
globalThis[HEALTH_MONITOR_KEY] ||
|
|
80
|
+
(globalThis[HEALTH_MONITOR_KEY] = createHealthMonitor());
|
|
81
|
+
|
|
82
|
+
// Cluster handles live on the `spice.database` globals, which a reconnect
|
|
83
|
+
// replaces. Instances read them at call time instead of copying them.
|
|
84
|
+
const HANDLE_GLOBALS = {
|
|
85
|
+
cluster: "clusters",
|
|
86
|
+
connection: "connections",
|
|
87
|
+
scope: "scopes",
|
|
88
|
+
bucket_handle: "buckets",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function globalHandle(kind, connection_name) {
|
|
92
|
+
if (typeof spice === "undefined") return undefined;
|
|
93
|
+
return spice?.database?.[HANDLE_GLOBALS[kind]]?.[connection_name];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requestedAutoOptimize(query_options) {
|
|
97
|
+
if (
|
|
98
|
+
!query_options ||
|
|
99
|
+
typeof query_options !== "object" ||
|
|
100
|
+
Array.isArray(query_options)
|
|
101
|
+
) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const value = query_options.auto_optimize ?? query_options.optimize;
|
|
105
|
+
return typeof value === "boolean" ? value : undefined;
|
|
106
|
+
}
|
|
53
107
|
|
|
54
108
|
function shouldOptimize(query) {
|
|
55
109
|
if (!AUTO_OPTIMIZE_ENABLED) return false;
|
|
@@ -126,17 +180,15 @@ module.exports = class Database {
|
|
|
126
180
|
scope,
|
|
127
181
|
query_scope = "cluster",
|
|
128
182
|
auto_create_collection,
|
|
183
|
+
auto_optimize,
|
|
184
|
+
auto_optimize_resources,
|
|
129
185
|
} = {},
|
|
130
186
|
) {
|
|
131
187
|
if (connection) {
|
|
132
188
|
this.connection_name = connection;
|
|
133
|
-
this.connection = spice.database.connections[connection];
|
|
134
189
|
this.query_scope = query_scope;
|
|
135
|
-
this.cluster = spice.database.clusters[connection];
|
|
136
190
|
this.bucket = this.connection._scope._bucket._name;
|
|
137
191
|
this.db_config = spice.config.database.connections[connection];
|
|
138
|
-
this.collection_manager =
|
|
139
|
-
spice.database.buckets[connection].collections();
|
|
140
192
|
this.auto_create_collection =
|
|
141
193
|
auto_create_collection ||
|
|
142
194
|
((collection ||
|
|
@@ -162,14 +214,11 @@ module.exports = class Database {
|
|
|
162
214
|
scope ||
|
|
163
215
|
spice.config.database.connections[connection].scope ||
|
|
164
216
|
"_default";
|
|
165
|
-
this.scope = spice.database.scopes[connection];
|
|
166
217
|
this.full_text_index =
|
|
167
218
|
spice.config.database.connections[connection].full_text_index ||
|
|
168
219
|
full_text_index;
|
|
169
220
|
this.N1qlQuery = couchbase.N1qlQuery;
|
|
170
221
|
this.SearchQuery = couchbase.SearchQuery;
|
|
171
|
-
this.collection = this.scope.collection(this.collection_name);
|
|
172
|
-
this.userManager = this.getUserManager(connection);
|
|
173
222
|
if (!spice?.db?.collections) {
|
|
174
223
|
spice.db = spice.db || {};
|
|
175
224
|
spice.db.collections = new Set();
|
|
@@ -181,7 +230,225 @@ module.exports = class Database {
|
|
|
181
230
|
this.use_bucket_name =
|
|
182
231
|
spice.config.database.connections[this.connection_name].scope ==
|
|
183
232
|
undefined;
|
|
233
|
+
|
|
234
|
+
// Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
|
|
235
|
+
const configured_auto_optimize =
|
|
236
|
+
auto_optimize ?? this.db_config.auto_optimize;
|
|
237
|
+
this.auto_optimize =
|
|
238
|
+
typeof configured_auto_optimize === "boolean"
|
|
239
|
+
? configured_auto_optimize
|
|
240
|
+
: undefined;
|
|
241
|
+
this.enabled_resources = parseResourceList([
|
|
242
|
+
...parseResourceList(this.db_config.auto_optimize_resources),
|
|
243
|
+
...parseResourceList(auto_optimize_resources),
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Handles resolve from the globals on every access, so an instance created
|
|
249
|
+
// before a reconnect uses the new cluster afterwards. Assigning one pins it
|
|
250
|
+
// on that instance instead (tests, custom wiring).
|
|
251
|
+
pinHandle(kind, value) {
|
|
252
|
+
if (!Object.prototype.hasOwnProperty.call(this, "_pinned_handles")) {
|
|
253
|
+
Object.defineProperty(this, "_pinned_handles", {
|
|
254
|
+
value: {},
|
|
255
|
+
enumerable: false,
|
|
256
|
+
writable: true,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
this._pinned_handles[kind] = value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pinnedHandle(kind) {
|
|
263
|
+
const pinned = this._pinned_handles;
|
|
264
|
+
return pinned && kind in pinned ? { value: pinned[kind] } : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
handle(kind) {
|
|
268
|
+
const pinned = this.pinnedHandle(kind);
|
|
269
|
+
return pinned ? pinned.value : globalHandle(kind, this.connection_name);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
get cluster() {
|
|
273
|
+
return this.handle("cluster");
|
|
274
|
+
}
|
|
275
|
+
set cluster(value) {
|
|
276
|
+
this.pinHandle("cluster", value);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get connection() {
|
|
280
|
+
return this.handle("connection");
|
|
281
|
+
}
|
|
282
|
+
set connection(value) {
|
|
283
|
+
this.pinHandle("connection", value);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
get scope() {
|
|
287
|
+
return this.handle("scope");
|
|
288
|
+
}
|
|
289
|
+
set scope(value) {
|
|
290
|
+
this.pinHandle("scope", value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
get collection() {
|
|
294
|
+
const pinned = this.pinnedHandle("collection");
|
|
295
|
+
if (pinned) return pinned.value;
|
|
296
|
+
const scope = this.scope;
|
|
297
|
+
if (!scope) return undefined;
|
|
298
|
+
// Collection handles are cheap but not free; cache one per scope object,
|
|
299
|
+
// which changes exactly when the connection is replaced.
|
|
300
|
+
if (this._collection_cache?.scope !== scope) {
|
|
301
|
+
Object.defineProperty(this, "_collection_cache", {
|
|
302
|
+
value: { scope, collection: scope.collection(this.collection_name) },
|
|
303
|
+
enumerable: false,
|
|
304
|
+
writable: true,
|
|
305
|
+
configurable: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return this._collection_cache.collection;
|
|
309
|
+
}
|
|
310
|
+
set collection(value) {
|
|
311
|
+
this.pinHandle("collection", value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
get collection_manager() {
|
|
315
|
+
const pinned = this.pinnedHandle("collection_manager");
|
|
316
|
+
if (pinned) return pinned.value;
|
|
317
|
+
return this.handle("bucket_handle")?.collections();
|
|
318
|
+
}
|
|
319
|
+
set collection_manager(value) {
|
|
320
|
+
this.pinHandle("collection_manager", value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get userManager() {
|
|
324
|
+
const pinned = this.pinnedHandle("userManager");
|
|
325
|
+
if (pinned) return pinned.value;
|
|
326
|
+
return this.cluster?.users();
|
|
327
|
+
}
|
|
328
|
+
set userManager(value) {
|
|
329
|
+
this.pinHandle("userManager", value);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reports a finished operation to the health monitor. Only connectivity
|
|
334
|
+
* errors (timeouts, cancelled requests, service not available) count.
|
|
335
|
+
*/
|
|
336
|
+
recordOutcome(error) {
|
|
337
|
+
health.recordOutcome(this.connection_name, error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
recordSettled(results) {
|
|
341
|
+
for (const result of results) {
|
|
342
|
+
if (result?.status === "rejected") this.recordOutcome(result.reason);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async observe(operation) {
|
|
347
|
+
try {
|
|
348
|
+
return await operation;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.recordOutcome(error);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Per-connection health: healthy, reconnecting, generation, last error. */
|
|
356
|
+
static healthStatus() {
|
|
357
|
+
return health.status();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Probes a connection now and reconnects it if the probe fails. */
|
|
361
|
+
static checkHealth(connection_name = "default") {
|
|
362
|
+
return health.check(connection_name);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Opens a fresh cluster for a connection and swaps it in. */
|
|
366
|
+
static forceReconnect(connection_name = "default") {
|
|
367
|
+
return health.reconnect(connection_name);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Stops probing and reconnecting and closes replaced clusters still in
|
|
372
|
+
* their grace period. Call before closing `spice.database.clusters` on
|
|
373
|
+
* shutdown so a reconnect cannot race it.
|
|
374
|
+
*/
|
|
375
|
+
static stopHealthMonitor() {
|
|
376
|
+
return health.stop();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Lets the application decide per resource whether slow statements may
|
|
381
|
+
* create covering indexes, e.g. from a per-resource setting an operator
|
|
382
|
+
* toggles at runtime. `policy(resource, { query, connection, collection })`
|
|
383
|
+
* returns true or false, or undefined to defer to the static lists. It is
|
|
384
|
+
* synchronous because it runs on the query path. Pass null to clear.
|
|
385
|
+
*/
|
|
386
|
+
static setAutoOptimizePolicy(policy) {
|
|
387
|
+
if (
|
|
388
|
+
policy !== null &&
|
|
389
|
+
policy !== undefined &&
|
|
390
|
+
typeof policy !== "function"
|
|
391
|
+
) {
|
|
392
|
+
throw new TypeError("Auto-optimize policy must be a function or null");
|
|
393
|
+
}
|
|
394
|
+
autoOptimizePolicy = policy || null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
static getAutoOptimizePolicy() {
|
|
398
|
+
return autoOptimizePolicy;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resources a statement touches, plus this instance's collection: that is
|
|
403
|
+
* the keyspace `optimize()` would create the index on.
|
|
404
|
+
*/
|
|
405
|
+
statementResources(query) {
|
|
406
|
+
const resources = extractStatementResources(query);
|
|
407
|
+
if (this.collection_name && this.collection_name !== "_default") {
|
|
408
|
+
resources.add(String(this.collection_name).toLowerCase());
|
|
184
409
|
}
|
|
410
|
+
return resources;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* True when a slow statement may create a covering index. See
|
|
415
|
+
* OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
|
|
416
|
+
*/
|
|
417
|
+
shouldAutoOptimize(query, query_options = {}) {
|
|
418
|
+
const requested = requestedAutoOptimize(query_options);
|
|
419
|
+
if (requested !== undefined) return requested;
|
|
420
|
+
if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
|
|
421
|
+
|
|
422
|
+
const resources = this.statementResources(query);
|
|
423
|
+
if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
|
|
424
|
+
let deferred = false;
|
|
425
|
+
for (const resource of resources) {
|
|
426
|
+
let verdict;
|
|
427
|
+
try {
|
|
428
|
+
verdict = autoOptimizePolicy(resource, {
|
|
429
|
+
query,
|
|
430
|
+
connection: this.connection_name,
|
|
431
|
+
collection: this.collection_name,
|
|
432
|
+
});
|
|
433
|
+
} catch (e) {
|
|
434
|
+
// A broken policy must not create indexes nobody asked for.
|
|
435
|
+
verdict = false;
|
|
436
|
+
}
|
|
437
|
+
if (verdict === true) return true;
|
|
438
|
+
if (verdict === undefined || verdict === null) deferred = true;
|
|
439
|
+
}
|
|
440
|
+
if (!deferred) return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const enabled = new Set([
|
|
444
|
+
...OPTIMIZE_ENABLED_RESOURCES,
|
|
445
|
+
...(this.enabled_resources || []),
|
|
446
|
+
]);
|
|
447
|
+
if (enabled.has("*")) return true;
|
|
448
|
+
for (const resource of resources) {
|
|
449
|
+
if (enabled.has(resource)) return true;
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
185
452
|
}
|
|
186
453
|
|
|
187
454
|
setGlobalConnectionObject(collection) {
|
|
@@ -203,26 +470,9 @@ module.exports = class Database {
|
|
|
203
470
|
}
|
|
204
471
|
|
|
205
472
|
async connect(connection_name, config) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
};
|
|
210
|
-
if (config.configProfile) {
|
|
211
|
-
connectOptions.configProfile = config.configProfile;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
let cluster = await couchbase.connect(config.nodes, connectOptions);
|
|
215
|
-
let bucket = cluster.bucket(config.bucket);
|
|
216
|
-
let collection;
|
|
217
|
-
let scope_name = this.scope_name || config.scope || "_default";
|
|
218
|
-
let collection_name =
|
|
219
|
-
this.collection_name || config.collection || "_default";
|
|
220
|
-
let scope = bucket.scope(scope_name);
|
|
221
|
-
|
|
222
|
-
collection = bucket
|
|
223
|
-
.scope(scope_name)
|
|
224
|
-
.collection(collection_name || "_default");
|
|
225
|
-
|
|
473
|
+
const { cluster, bucket, scope, collection } = await openHandles(config);
|
|
474
|
+
// Watch the connection from here on and replace it if it wedges.
|
|
475
|
+
health.register(connection_name, config);
|
|
226
476
|
return {
|
|
227
477
|
connection: connection_name,
|
|
228
478
|
bucket,
|
|
@@ -233,7 +483,7 @@ module.exports = class Database {
|
|
|
233
483
|
}
|
|
234
484
|
|
|
235
485
|
getUserManager(connection) {
|
|
236
|
-
return
|
|
486
|
+
return globalHandle("cluster", connection).users();
|
|
237
487
|
}
|
|
238
488
|
|
|
239
489
|
async assignUserPermission() {
|
|
@@ -413,7 +663,7 @@ module.exports = class Database {
|
|
|
413
663
|
this.use_bucket_name ? this.connection : this.collection;
|
|
414
664
|
|
|
415
665
|
if (!useLookupIn) {
|
|
416
|
-
let response = await collection.get(doc_id);
|
|
666
|
+
let response = await this.observe(collection.get(doc_id));
|
|
417
667
|
return { id: doc_id, ...response.content };
|
|
418
668
|
}
|
|
419
669
|
|
|
@@ -428,6 +678,7 @@ module.exports = class Database {
|
|
|
428
678
|
});
|
|
429
679
|
|
|
430
680
|
const batchResults = await Promise.allSettled(batchPromises);
|
|
681
|
+
this.recordSettled(batchResults);
|
|
431
682
|
|
|
432
683
|
// Merge results from all batches
|
|
433
684
|
batchResults.forEach((result, batchIndex) => {
|
|
@@ -449,12 +700,14 @@ module.exports = class Database {
|
|
|
449
700
|
|
|
450
701
|
async insert(doc_id, document, expiry) {
|
|
451
702
|
try {
|
|
452
|
-
await
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
703
|
+
await this.observe(
|
|
704
|
+
(this.use_bucket_name ? this.connection : this.collection).insert(
|
|
705
|
+
doc_id,
|
|
706
|
+
document,
|
|
707
|
+
{
|
|
708
|
+
expiry: expiry,
|
|
709
|
+
},
|
|
710
|
+
),
|
|
458
711
|
);
|
|
459
712
|
} catch (e) {
|
|
460
713
|
console.log(e.message, e, doc_id);
|
|
@@ -463,38 +716,45 @@ module.exports = class Database {
|
|
|
463
716
|
}
|
|
464
717
|
|
|
465
718
|
async touch(doc_id, expiry = 0) {
|
|
466
|
-
await
|
|
467
|
-
|
|
468
|
-
|
|
719
|
+
await this.observe(
|
|
720
|
+
(this.use_bucket_name ? this.connection : this.collection).touch(
|
|
721
|
+
doc_id,
|
|
722
|
+
expiry,
|
|
723
|
+
),
|
|
469
724
|
);
|
|
470
725
|
return await this.get(doc_id);
|
|
471
726
|
}
|
|
472
727
|
|
|
473
728
|
async count(doc_id, adjustment, initial_value) {
|
|
474
|
-
let res = await (
|
|
475
|
-
this.use_bucket_name ?
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
729
|
+
let res = await this.observe(
|
|
730
|
+
(this.use_bucket_name ? this.connection : this.collection).counter(
|
|
731
|
+
doc_id,
|
|
732
|
+
adjustment,
|
|
733
|
+
{
|
|
734
|
+
initial: initial_value,
|
|
735
|
+
},
|
|
736
|
+
),
|
|
737
|
+
);
|
|
480
738
|
return res.value;
|
|
481
739
|
}
|
|
482
740
|
|
|
483
741
|
async update(doc_id, document, expiry) {
|
|
484
|
-
let res = await (
|
|
485
|
-
this.use_bucket_name ?
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
742
|
+
let res = await this.observe(
|
|
743
|
+
(this.use_bucket_name ? this.connection : this.collection).replace(
|
|
744
|
+
doc_id,
|
|
745
|
+
document,
|
|
746
|
+
{
|
|
747
|
+
expiry: expiry,
|
|
748
|
+
},
|
|
749
|
+
),
|
|
750
|
+
);
|
|
490
751
|
return res.value;
|
|
491
752
|
}
|
|
492
753
|
|
|
493
754
|
async delete(doc_id) {
|
|
494
|
-
let res = await (
|
|
495
|
-
this.use_bucket_name ?
|
|
496
|
-
|
|
497
|
-
: this.collection).remove(doc_id);
|
|
755
|
+
let res = await this.observe(
|
|
756
|
+
(this.use_bucket_name ? this.connection : this.collection).remove(doc_id),
|
|
757
|
+
);
|
|
498
758
|
return res.value;
|
|
499
759
|
}
|
|
500
760
|
|
|
@@ -511,6 +771,7 @@ module.exports = class Database {
|
|
|
511
771
|
// Regular get - no batching needed
|
|
512
772
|
let functions = _.map(keys, (key) => collection.get(key));
|
|
513
773
|
let returned_all = await Promise.allSettled(functions);
|
|
774
|
+
this.recordSettled(returned_all);
|
|
514
775
|
|
|
515
776
|
let responses = _.flatten(
|
|
516
777
|
_.map(returned_all, (returned_obj) => returned_obj?.value),
|
|
@@ -549,6 +810,7 @@ module.exports = class Database {
|
|
|
549
810
|
});
|
|
550
811
|
|
|
551
812
|
const batchResults = await Promise.allSettled(batchPromises);
|
|
813
|
+
this.recordSettled(batchResults);
|
|
552
814
|
|
|
553
815
|
// Merge results from all batches
|
|
554
816
|
batchResults.forEach((result, batchIndex) => {
|
|
@@ -947,7 +1209,9 @@ module.exports = class Database {
|
|
|
947
1209
|
// Start time
|
|
948
1210
|
let startTime = process.hrtime.bigint();
|
|
949
1211
|
const options = buildQueryOptions(query_options);
|
|
950
|
-
let response = await
|
|
1212
|
+
let response = await this.observe(
|
|
1213
|
+
exec_scope.query(sanitizedQuery, options),
|
|
1214
|
+
);
|
|
951
1215
|
// End time
|
|
952
1216
|
let endTime = process.hrtime.bigint();
|
|
953
1217
|
// Calculate duration in microseconds
|
|
@@ -956,6 +1220,7 @@ module.exports = class Database {
|
|
|
956
1220
|
// declared at the top of this file. Never awaited, never allowed to reject.
|
|
957
1221
|
if (
|
|
958
1222
|
durationInMilliseconds > SLOW_QUERY_MS &&
|
|
1223
|
+
this.shouldAutoOptimize(sanitizedQuery, query_options) &&
|
|
959
1224
|
shouldOptimize(sanitizedQuery)
|
|
960
1225
|
) {
|
|
961
1226
|
this.optimize(sanitizedQuery, exec_scope).catch(() => {});
|
package/src/utility.js
CHANGED
|
@@ -458,10 +458,73 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
|
458
458
|
return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Normalizes an exemption list into a Set of lower-case resource names.
|
|
463
|
+
* Accepts an array, a Set, or a comma-separated string (the env var form).
|
|
464
|
+
* Surrounding whitespace and backticks are ignored; empty entries are dropped.
|
|
465
|
+
* @param {string|string[]|Set<string>} value
|
|
466
|
+
* @returns {Set<string>}
|
|
467
|
+
*/
|
|
468
|
+
function parseResourceList(value) {
|
|
469
|
+
const names = new Set();
|
|
470
|
+
let items = [];
|
|
471
|
+
if (Array.isArray(value) || value instanceof Set) {
|
|
472
|
+
items = [...value];
|
|
473
|
+
} else if (typeof value === "string") {
|
|
474
|
+
items = value.split(",");
|
|
475
|
+
}
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
if (typeof item !== "string") continue;
|
|
478
|
+
const name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
|
|
479
|
+
if (name) names.add(name);
|
|
480
|
+
}
|
|
481
|
+
return names;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Extracts the resource names a statement touches, lower-cased:
|
|
486
|
+
* - the last path segment of every FROM / JOIN keyspace, unquoted, so
|
|
487
|
+
* `default`:`bucket`.`scope`.`users` and users both yield "users";
|
|
488
|
+
* - every `type = '...'` literal, which is how legacy bucket-mode
|
|
489
|
+
* deployments distinguish resources that share one keyspace.
|
|
490
|
+
* Only simple identifiers are recognised; subqueries and expressions are skipped.
|
|
491
|
+
* @param {string} query
|
|
492
|
+
* @returns {Set<string>}
|
|
493
|
+
*/
|
|
494
|
+
function extractStatementResources(query) {
|
|
495
|
+
const names = new Set();
|
|
496
|
+
if (typeof query !== "string") return names;
|
|
497
|
+
|
|
498
|
+
const identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
|
|
499
|
+
const keyspacePattern = new RegExp(
|
|
500
|
+
`\\b(?:FROM|JOIN)\\s+(${identifier}(?:\\s*[.:]\\s*${identifier})*)`,
|
|
501
|
+
"gi",
|
|
502
|
+
);
|
|
503
|
+
const segmentPattern = new RegExp(identifier, "g");
|
|
504
|
+
for (const match of query.matchAll(keyspacePattern)) {
|
|
505
|
+
const segments = match[1].match(segmentPattern) || [];
|
|
506
|
+
const last = (segments[segments.length - 1] || "").trim();
|
|
507
|
+
const name = last.startsWith("`")
|
|
508
|
+
? last.slice(1, -1).replace(/``/g, "`")
|
|
509
|
+
: last;
|
|
510
|
+
if (name) names.add(name.toLowerCase());
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
|
|
514
|
+
for (const match of query.matchAll(typePattern)) {
|
|
515
|
+
const name = match[1].replace(/''/g, "'").trim();
|
|
516
|
+
if (name) names.add(name.toLowerCase());
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return names;
|
|
520
|
+
}
|
|
521
|
+
|
|
461
522
|
module.exports = {
|
|
462
523
|
buildCoveringIndex,
|
|
463
524
|
buildCoveringIndexDefinition,
|
|
464
525
|
indexSatisfiesCoveringDefinition,
|
|
526
|
+
parseResourceList,
|
|
527
|
+
extractStatementResources,
|
|
465
528
|
extractWhereFields,
|
|
466
529
|
extractSelectFields,
|
|
467
530
|
extractGroupByFields,
|
package/src/utility.test.js
CHANGED
|
@@ -8,6 +8,8 @@ const {
|
|
|
8
8
|
extractGroupByFields,
|
|
9
9
|
extractOrderByFields,
|
|
10
10
|
generateHash,
|
|
11
|
+
parseResourceList,
|
|
12
|
+
extractStatementResources,
|
|
11
13
|
} = require("./utility");
|
|
12
14
|
|
|
13
15
|
// Test utilities
|
|
@@ -1046,6 +1048,73 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
|
|
|
1046
1048
|
assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
|
|
1047
1049
|
});
|
|
1048
1050
|
|
|
1051
|
+
// ============================================
|
|
1052
|
+
// Tests for parseResourceList / extractStatementResources
|
|
1053
|
+
// ============================================
|
|
1054
|
+
console.log("\n--- parseResourceList tests ---");
|
|
1055
|
+
|
|
1056
|
+
test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
|
|
1057
|
+
const result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
|
|
1058
|
+
assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test("accepts arrays and Sets and ignores non-strings", () => {
|
|
1062
|
+
assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
|
|
1063
|
+
assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("returns an empty set for undefined, empty or unsupported input", () => {
|
|
1067
|
+
assert.strictEqual(parseResourceList(undefined).size, 0);
|
|
1068
|
+
assert.strictEqual(parseResourceList("").size, 0);
|
|
1069
|
+
assert.strictEqual(parseResourceList(", ,").size, 0);
|
|
1070
|
+
assert.strictEqual(parseResourceList({ users: true }).size, 0);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
console.log("\n--- extractStatementResources tests ---");
|
|
1074
|
+
|
|
1075
|
+
test("extracts a bare FROM keyspace", () => {
|
|
1076
|
+
assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test("extracts the last segment of a qualified or quoted keyspace", () => {
|
|
1080
|
+
assert.deepStrictEqual(
|
|
1081
|
+
[...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")],
|
|
1082
|
+
["users"],
|
|
1083
|
+
);
|
|
1084
|
+
assert.deepStrictEqual(
|
|
1085
|
+
[...extractStatementResources("SELECT name FROM saturn.app.logs")],
|
|
1086
|
+
["logs"],
|
|
1087
|
+
);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test("extracts JOIN keyspaces alongside FROM", () => {
|
|
1091
|
+
const result = extractStatementResources(
|
|
1092
|
+
"SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id",
|
|
1093
|
+
);
|
|
1094
|
+
assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test("extracts type literals used by legacy bucket mode", () => {
|
|
1098
|
+
const result = extractStatementResources(
|
|
1099
|
+
"SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false",
|
|
1100
|
+
);
|
|
1101
|
+
assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
|
|
1102
|
+
assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
|
|
1103
|
+
assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
|
|
1107
|
+
const result = extractStatementResources(
|
|
1108
|
+
"SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'",
|
|
1109
|
+
);
|
|
1110
|
+
assert.deepStrictEqual([...result], ["users"]);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test("handles non-string input", () => {
|
|
1114
|
+
assert.strictEqual(extractStatementResources(undefined).size, 0);
|
|
1115
|
+
assert.strictEqual(extractStatementResources(null).size, 0);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1049
1118
|
// ============================================
|
|
1050
1119
|
// Summary
|
|
1051
1120
|
// ============================================
|