spice-couchbase 2.0.61 → 2.0.62
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/index.auto-optimize.test.js +204 -2
- package/build/index.js +124 -4
- package/build/utility.js +65 -0
- package/build/utility.test.js +49 -1
- package/package.json +1 -1
- package/src/index.auto-optimize.test.js +158 -2
- package/src/index.js +122 -0
- package/src/utility.js +63 -0
- package/src/utility.test.js +69 -0
|
@@ -94,9 +94,10 @@ function createStatements(scope) {
|
|
|
94
94
|
return statement.startsWith("CREATE INDEX ");
|
|
95
95
|
});
|
|
96
96
|
} // Isolate optimizer state and time without connecting to Couchbase or sleeping.
|
|
97
|
+
// Each load is a fresh module: its own seen-set, cooldown and policy.
|
|
97
98
|
|
|
98
99
|
|
|
99
|
-
function
|
|
100
|
+
function loadDriver(durationMs) {
|
|
100
101
|
var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
101
102
|
var tick = BigInt(0);
|
|
102
103
|
var module = {
|
|
@@ -117,7 +118,11 @@ function createDatabase(durationMs) {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
});
|
|
120
|
-
|
|
121
|
+
return module.exports;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function instanceOf(Database) {
|
|
125
|
+
var db = Object.create(Database.prototype);
|
|
121
126
|
db.bucket = "saturn";
|
|
122
127
|
db.scope_name = "test";
|
|
123
128
|
db.collection_name = "users";
|
|
@@ -137,6 +142,14 @@ function createDatabase(durationMs) {
|
|
|
137
142
|
}()
|
|
138
143
|
};
|
|
139
144
|
return db;
|
|
145
|
+
} // Opted in wholesale: per-resource resolution has its own tests further down.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
function createDatabase(durationMs) {
|
|
149
|
+
var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
150
|
+
var db = instanceOf(loadDriver(durationMs, env));
|
|
151
|
+
db.auto_optimize = true;
|
|
152
|
+
return db;
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
test("auto-optimization only starts above the default 3-second threshold",
|
|
@@ -373,4 +386,193 @@ _asyncToGenerator(function* () {
|
|
|
373
386
|
collection: "_default"
|
|
374
387
|
}));
|
|
375
388
|
assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
|
|
389
|
+
})); // ---- Per-resource opt-in for auto cover indexing ----
|
|
390
|
+
// Nothing decided at instance level: resolution falls through to the call,
|
|
391
|
+
// the application policy and the static lists.
|
|
392
|
+
|
|
393
|
+
function createUnconfiguredDatabase() {
|
|
394
|
+
var env = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
395
|
+
return instanceOf(loadDriver(3001, env));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function optimizationsFor(_x2, _x3) {
|
|
399
|
+
return _optimizationsFor.apply(this, arguments);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function _optimizationsFor() {
|
|
403
|
+
_optimizationsFor = _asyncToGenerator(function* (db, statement) {
|
|
404
|
+
var optimizations = 0;
|
|
405
|
+
db.optimize =
|
|
406
|
+
/*#__PURE__*/
|
|
407
|
+
_asyncToGenerator(function* () {
|
|
408
|
+
optimizations += 1;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
for (var _len = arguments.length, queryArgs = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
|
|
412
|
+
queryArgs[_key - 2] = arguments[_key];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
yield db.query(statement, ...queryArgs);
|
|
416
|
+
return optimizations;
|
|
417
|
+
});
|
|
418
|
+
return _optimizationsFor.apply(this, arguments);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
var slowLookup = "SELECT name FROM users WHERE status = 'active'";
|
|
422
|
+
var EVERY_RESOURCE = {
|
|
423
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "*"
|
|
424
|
+
};
|
|
425
|
+
test("no resource is auto-indexed unless something opts it in",
|
|
426
|
+
/*#__PURE__*/
|
|
427
|
+
_asyncToGenerator(function* () {
|
|
428
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup)), 0);
|
|
429
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase({
|
|
430
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: ""
|
|
431
|
+
}), slowLookup)), 0);
|
|
432
|
+
}));
|
|
433
|
+
test("SPICE_COUCHBASE_OPTIMIZE_RESOURCES enables the instance's collection, case-insensitively",
|
|
434
|
+
/*#__PURE__*/
|
|
435
|
+
_asyncToGenerator(function* () {
|
|
436
|
+
var env = {
|
|
437
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: " Users ,audit_log,, `sessions` "
|
|
438
|
+
};
|
|
439
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(env), slowLookup)), 1);
|
|
440
|
+
var sessions = createUnconfiguredDatabase(env);
|
|
441
|
+
sessions.collection_name = "sessions";
|
|
442
|
+
assert.equal((yield optimizationsFor(sessions, "SELECT id FROM sessions")), 1);
|
|
443
|
+
var orders = createUnconfiguredDatabase(env);
|
|
444
|
+
orders.collection_name = "orders";
|
|
445
|
+
assert.equal((yield optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'")), 0);
|
|
446
|
+
}));
|
|
447
|
+
test("a wildcard restores auto indexing for every resource",
|
|
448
|
+
/*#__PURE__*/
|
|
449
|
+
_asyncToGenerator(function* () {
|
|
450
|
+
var db = createUnconfiguredDatabase(EVERY_RESOURCE);
|
|
451
|
+
db.collection_name = "anything";
|
|
452
|
+
assert.equal((yield optimizationsFor(db, "SELECT a FROM anything WHERE b = 1")), 1);
|
|
453
|
+
}));
|
|
454
|
+
test("legacy bucket mode resolves the resource from the type literal the driver adds",
|
|
455
|
+
/*#__PURE__*/
|
|
456
|
+
_asyncToGenerator(function* () {
|
|
457
|
+
var env = {
|
|
458
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "audit_log"
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
for (var [type, expected] of [["audit_log", 1], ["users", 0]]) {
|
|
462
|
+
var db = createUnconfiguredDatabase(env);
|
|
463
|
+
db.collection_name = "_default";
|
|
464
|
+
db.scope_name = "_default";
|
|
465
|
+
assert.equal((yield optimizationsFor(db, "SELECT actor FROM `saturn` WHERE type = '".concat(type, "' AND deleted = false"))), expected, type);
|
|
466
|
+
}
|
|
467
|
+
}));
|
|
468
|
+
test("a fully qualified or aliased FROM keyspace matches on its last segment",
|
|
469
|
+
/*#__PURE__*/
|
|
470
|
+
_asyncToGenerator(function* () {
|
|
471
|
+
var env = {
|
|
472
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "logs"
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
for (var statement of ["SELECT level FROM `default`:`saturn`.`app`.`logs` WHERE level = 'error'", "SELECT l.level FROM saturn.app.logs AS l WHERE l.level = 'error'", "SELECT u.name FROM users AS u JOIN `logs` AS l ON l.user_id = META(u).id"]) {
|
|
476
|
+
var db = createUnconfiguredDatabase(env);
|
|
477
|
+
db.collection_name = "_default";
|
|
478
|
+
assert.equal((yield optimizationsFor(db, statement)), 1, statement);
|
|
479
|
+
}
|
|
480
|
+
}));
|
|
481
|
+
test("connection or constructor lists work without the environment variable",
|
|
482
|
+
/*#__PURE__*/
|
|
483
|
+
_asyncToGenerator(function* () {
|
|
484
|
+
var db = createUnconfiguredDatabase();
|
|
485
|
+
db.enabled_resources = new Set(["users"]);
|
|
486
|
+
assert.equal((yield optimizationsFor(db, slowLookup)), 1);
|
|
487
|
+
}));
|
|
488
|
+
test("an explicit instance setting overrides every resource list",
|
|
489
|
+
/*#__PURE__*/
|
|
490
|
+
_asyncToGenerator(function* () {
|
|
491
|
+
var on = createUnconfiguredDatabase();
|
|
492
|
+
on.auto_optimize = true;
|
|
493
|
+
assert.equal((yield optimizationsFor(on, slowLookup)), 1);
|
|
494
|
+
var off = createUnconfiguredDatabase(EVERY_RESOURCE);
|
|
495
|
+
off.auto_optimize = false;
|
|
496
|
+
assert.equal((yield optimizationsFor(off, slowLookup)), 0);
|
|
497
|
+
}));
|
|
498
|
+
test("a single call can opt in or out through query options",
|
|
499
|
+
/*#__PURE__*/
|
|
500
|
+
_asyncToGenerator(function* () {
|
|
501
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup, {
|
|
502
|
+
auto_optimize: true
|
|
503
|
+
})), 1);
|
|
504
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, {
|
|
505
|
+
auto_optimize: false
|
|
506
|
+
})), 0);
|
|
507
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", {
|
|
508
|
+
optimize: false
|
|
509
|
+
})), 0); // The scan-consistency shorthand is not an opt-in or opt-out.
|
|
510
|
+
|
|
511
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", true)), 1);
|
|
512
|
+
assert.equal((yield optimizationsFor(createUnconfiguredDatabase(), slowLookup, "cluster", true)), 0);
|
|
513
|
+
}));
|
|
514
|
+
test("an application policy decides per resource and sees the statement context",
|
|
515
|
+
/*#__PURE__*/
|
|
516
|
+
_asyncToGenerator(function* () {
|
|
517
|
+
var Database = loadDriver(3001);
|
|
518
|
+
var seen = [];
|
|
519
|
+
Database.setAutoOptimizePolicy((resource, context) => {
|
|
520
|
+
seen.push([resource, context]);
|
|
521
|
+
return resource === "users";
|
|
522
|
+
});
|
|
523
|
+
assert.equal(Database.getAutoOptimizePolicy().length, 2);
|
|
524
|
+
var users = instanceOf(Database);
|
|
525
|
+
users.connection_name = "default";
|
|
526
|
+
assert.equal((yield optimizationsFor(users, slowLookup)), 1); // Objects cross the vm boundary, so compare by value rather than prototype.
|
|
527
|
+
|
|
528
|
+
assert.equal(JSON.stringify(seen[0]), JSON.stringify(["users", {
|
|
529
|
+
query: slowLookup,
|
|
530
|
+
connection: "default",
|
|
531
|
+
collection: "users"
|
|
532
|
+
}]));
|
|
533
|
+
var orders = instanceOf(Database);
|
|
534
|
+
orders.collection_name = "orders";
|
|
535
|
+
assert.equal((yield optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'")), 0);
|
|
536
|
+
}));
|
|
537
|
+
test("a policy that returns undefined defers to the static lists",
|
|
538
|
+
/*#__PURE__*/
|
|
539
|
+
_asyncToGenerator(function* () {
|
|
540
|
+
for (var [env, expected] of [[{
|
|
541
|
+
SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "users"
|
|
542
|
+
}, 1], [{}, 0]]) {
|
|
543
|
+
var Database = loadDriver(3001, env);
|
|
544
|
+
Database.setAutoOptimizePolicy(() => undefined);
|
|
545
|
+
assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), expected);
|
|
546
|
+
}
|
|
547
|
+
}));
|
|
548
|
+
test("an explicit policy refusal wins over the wildcard, and a throwing policy counts as a refusal",
|
|
549
|
+
/*#__PURE__*/
|
|
550
|
+
_asyncToGenerator(function* () {
|
|
551
|
+
for (var policy of [() => false, () => {
|
|
552
|
+
throw new Error("settings unavailable");
|
|
553
|
+
}]) {
|
|
554
|
+
var Database = loadDriver(3001, EVERY_RESOURCE);
|
|
555
|
+
Database.setAutoOptimizePolicy(policy);
|
|
556
|
+
assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), 0);
|
|
557
|
+
}
|
|
558
|
+
}));
|
|
559
|
+
test("the policy can be cleared and must be a function",
|
|
560
|
+
/*#__PURE__*/
|
|
561
|
+
_asyncToGenerator(function* () {
|
|
562
|
+
var Database = loadDriver(3001, EVERY_RESOURCE);
|
|
563
|
+
Database.setAutoOptimizePolicy(() => false);
|
|
564
|
+
Database.setAutoOptimizePolicy(null);
|
|
565
|
+
assert.equal(Database.getAutoOptimizePolicy(), null);
|
|
566
|
+
assert.equal((yield optimizationsFor(instanceOf(Database), slowLookup)), 1);
|
|
567
|
+
assert.throws(() => Database.setAutoOptimizePolicy("users"), {
|
|
568
|
+
name: "TypeError"
|
|
569
|
+
});
|
|
570
|
+
}));
|
|
571
|
+
test("a declined statement is not remembered as already optimised",
|
|
572
|
+
/*#__PURE__*/
|
|
573
|
+
_asyncToGenerator(function* () {
|
|
574
|
+
var db = createUnconfiguredDatabase();
|
|
575
|
+
assert.equal((yield optimizationsFor(db, slowLookup)), 0);
|
|
576
|
+
db.auto_optimize = true;
|
|
577
|
+
assert.equal((yield optimizationsFor(db, slowLookup)), 1);
|
|
376
578
|
}));
|
package/build/index.js
CHANGED
|
@@ -24,6 +24,11 @@ var {
|
|
|
24
24
|
QueryScanConsistency
|
|
25
25
|
} = require("couchbase");
|
|
26
26
|
|
|
27
|
+
var {
|
|
28
|
+
parseResourceList,
|
|
29
|
+
extractStatementResources
|
|
30
|
+
} = require("./utility");
|
|
31
|
+
|
|
27
32
|
if (!Promise.allSettled) {
|
|
28
33
|
Promise.allSettled = promises => Promise.all(promises.map((promise, i) => promise.then(value => ({
|
|
29
34
|
status: "fulfilled",
|
|
@@ -52,7 +57,35 @@ var optimizeState = {
|
|
|
52
57
|
inFlight: 0,
|
|
53
58
|
seen: new Set(),
|
|
54
59
|
cooldownUntil: 0
|
|
55
|
-
};
|
|
60
|
+
}; // Auto cover indexing is opt-in per resource. A resource is the collection a
|
|
61
|
+
// Database instance serves (scoped deployments) or the `type = '...'` literal
|
|
62
|
+
// legacy bucket mode adds to every statement; raw statements also count their
|
|
63
|
+
// FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
|
|
64
|
+
// optimised when, in order of precedence:
|
|
65
|
+
// 1. the call says so: query_options.auto_optimize (true / false)
|
|
66
|
+
// 2. the instance says so: constructor option or connection config
|
|
67
|
+
// `auto_optimize` (true / false)
|
|
68
|
+
// 3. the application policy: Database.setAutoOptimizePolicy(fn), where
|
|
69
|
+
// fn(resource, context) returns true / false, or
|
|
70
|
+
// undefined to defer to the static lists
|
|
71
|
+
// 4. a static list names a resource it touches: comma-separated
|
|
72
|
+
// SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
|
|
73
|
+
// pre-2.0.62 behaviour), or connection config / constructor option
|
|
74
|
+
// `auto_optimize_resources`.
|
|
75
|
+
|
|
76
|
+
var OPTIMIZE_ENABLED_RESOURCES = parseResourceList(process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES);
|
|
77
|
+
var autoOptimizePolicy = null;
|
|
78
|
+
|
|
79
|
+
function requestedAutoOptimize(query_options) {
|
|
80
|
+
var _query_options$auto_o;
|
|
81
|
+
|
|
82
|
+
if (!query_options || typeof query_options !== "object" || Array.isArray(query_options)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var value = (_query_options$auto_o = query_options.auto_optimize) !== null && _query_options$auto_o !== void 0 ? _query_options$auto_o : query_options.optimize;
|
|
87
|
+
return typeof value === "boolean" ? value : undefined;
|
|
88
|
+
}
|
|
56
89
|
|
|
57
90
|
function shouldOptimize(query) {
|
|
58
91
|
if (!AUTO_OPTIMIZE_ENABLED) return false;
|
|
@@ -124,7 +157,9 @@ module.exports = class Database {
|
|
|
124
157
|
collection,
|
|
125
158
|
scope,
|
|
126
159
|
query_scope = "cluster",
|
|
127
|
-
auto_create_collection
|
|
160
|
+
auto_create_collection,
|
|
161
|
+
auto_optimize,
|
|
162
|
+
auto_optimize_resources
|
|
128
163
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
129
164
|
|
|
130
165
|
if (connection) {
|
|
@@ -163,8 +198,93 @@ module.exports = class Database {
|
|
|
163
198
|
this.upsertCollection();
|
|
164
199
|
}
|
|
165
200
|
|
|
166
|
-
this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined;
|
|
201
|
+
this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined; // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
|
|
202
|
+
|
|
203
|
+
var configured_auto_optimize = auto_optimize !== null && auto_optimize !== void 0 ? auto_optimize : this.db_config.auto_optimize;
|
|
204
|
+
this.auto_optimize = typeof configured_auto_optimize === "boolean" ? configured_auto_optimize : undefined;
|
|
205
|
+
this.enabled_resources = parseResourceList([...parseResourceList(this.db_config.auto_optimize_resources), ...parseResourceList(auto_optimize_resources)]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Lets the application decide per resource whether slow statements may
|
|
210
|
+
* create covering indexes, e.g. from a per-resource setting an operator
|
|
211
|
+
* toggles at runtime. `policy(resource, { query, connection, collection })`
|
|
212
|
+
* returns true or false, or undefined to defer to the static lists. It is
|
|
213
|
+
* synchronous because it runs on the query path. Pass null to clear.
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
static setAutoOptimizePolicy(policy) {
|
|
218
|
+
if (policy !== null && policy !== undefined && typeof policy !== "function") {
|
|
219
|
+
throw new TypeError("Auto-optimize policy must be a function or null");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
autoOptimizePolicy = policy || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
static getAutoOptimizePolicy() {
|
|
226
|
+
return autoOptimizePolicy;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Resources a statement touches, plus this instance's collection: that is
|
|
230
|
+
* the keyspace `optimize()` would create the index on.
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
statementResources(query) {
|
|
235
|
+
var resources = extractStatementResources(query);
|
|
236
|
+
|
|
237
|
+
if (this.collection_name && this.collection_name !== "_default") {
|
|
238
|
+
resources.add(String(this.collection_name).toLowerCase());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return resources;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* True when a slow statement may create a covering index. See
|
|
245
|
+
* OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
shouldAutoOptimize(query) {
|
|
250
|
+
var query_options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
251
|
+
var requested = requestedAutoOptimize(query_options);
|
|
252
|
+
if (requested !== undefined) return requested;
|
|
253
|
+
if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
|
|
254
|
+
var resources = this.statementResources(query);
|
|
255
|
+
|
|
256
|
+
if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
|
|
257
|
+
var deferred = false;
|
|
258
|
+
|
|
259
|
+
for (var resource of resources) {
|
|
260
|
+
var verdict = void 0;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
verdict = autoOptimizePolicy(resource, {
|
|
264
|
+
query,
|
|
265
|
+
connection: this.connection_name,
|
|
266
|
+
collection: this.collection_name
|
|
267
|
+
});
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// A broken policy must not create indexes nobody asked for.
|
|
270
|
+
verdict = false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (verdict === true) return true;
|
|
274
|
+
if (verdict === undefined || verdict === null) deferred = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!deferred) return false;
|
|
167
278
|
}
|
|
279
|
+
|
|
280
|
+
var enabled = new Set([...OPTIMIZE_ENABLED_RESOURCES, ...(this.enabled_resources || [])]);
|
|
281
|
+
if (enabled.has("*")) return true;
|
|
282
|
+
|
|
283
|
+
for (var _resource of resources) {
|
|
284
|
+
if (enabled.has(_resource)) return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return false;
|
|
168
288
|
}
|
|
169
289
|
|
|
170
290
|
setGlobalConnectionObject(collection) {
|
|
@@ -993,7 +1113,7 @@ module.exports = class Database {
|
|
|
993
1113
|
var durationInMilliseconds = (endTime - startTime) / BigInt(1000000); // Optimise slow statements in the background, subject to the guard rails
|
|
994
1114
|
// declared at the top of this file. Never awaited, never allowed to reject.
|
|
995
1115
|
|
|
996
|
-
if (durationInMilliseconds > SLOW_QUERY_MS && shouldOptimize(sanitizedQuery)) {
|
|
1116
|
+
if (durationInMilliseconds > SLOW_QUERY_MS && _this20.shouldAutoOptimize(sanitizedQuery, query_options) && shouldOptimize(sanitizedQuery)) {
|
|
997
1117
|
_this20.optimize(sanitizedQuery, exec_scope).catch(() => {});
|
|
998
1118
|
}
|
|
999
1119
|
|
package/build/utility.js
CHANGED
|
@@ -435,11 +435,76 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
|
|
|
435
435
|
var existingFields = new Set(existingKeys.map(key => key.field));
|
|
436
436
|
return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
|
|
437
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Normalizes an exemption list into a Set of lower-case resource names.
|
|
440
|
+
* Accepts an array, a Set, or a comma-separated string (the env var form).
|
|
441
|
+
* Surrounding whitespace and backticks are ignored; empty entries are dropped.
|
|
442
|
+
* @param {string|string[]|Set<string>} value
|
|
443
|
+
* @returns {Set<string>}
|
|
444
|
+
*/
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
function parseResourceList(value) {
|
|
448
|
+
var names = new Set();
|
|
449
|
+
var items = [];
|
|
450
|
+
|
|
451
|
+
if (Array.isArray(value) || value instanceof Set) {
|
|
452
|
+
items = [...value];
|
|
453
|
+
} else if (typeof value === "string") {
|
|
454
|
+
items = value.split(",");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (var item of items) {
|
|
458
|
+
if (typeof item !== "string") continue;
|
|
459
|
+
var name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
|
|
460
|
+
if (name) names.add(name);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return names;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Extracts the resource names a statement touches, lower-cased:
|
|
467
|
+
* - the last path segment of every FROM / JOIN keyspace, unquoted, so
|
|
468
|
+
* `default`:`bucket`.`scope`.`users` and users both yield "users";
|
|
469
|
+
* - every `type = '...'` literal, which is how legacy bucket-mode
|
|
470
|
+
* deployments distinguish resources that share one keyspace.
|
|
471
|
+
* Only simple identifiers are recognised; subqueries and expressions are skipped.
|
|
472
|
+
* @param {string} query
|
|
473
|
+
* @returns {Set<string>}
|
|
474
|
+
*/
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
function extractStatementResources(query) {
|
|
478
|
+
var names = new Set();
|
|
479
|
+
if (typeof query !== "string") return names;
|
|
480
|
+
var identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
|
|
481
|
+
var keyspacePattern = new RegExp("\\b(?:FROM|JOIN)\\s+(".concat(identifier, "(?:\\s*[.:]\\s*").concat(identifier, ")*)"), "gi");
|
|
482
|
+
var segmentPattern = new RegExp(identifier, "g");
|
|
483
|
+
|
|
484
|
+
for (var match of query.matchAll(keyspacePattern)) {
|
|
485
|
+
var segments = match[1].match(segmentPattern) || [];
|
|
486
|
+
var last = (segments[segments.length - 1] || "").trim();
|
|
487
|
+
var name = last.startsWith("`") ? last.slice(1, -1).replace(/``/g, "`") : last;
|
|
488
|
+
if (name) names.add(name.toLowerCase());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
var typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
|
|
492
|
+
|
|
493
|
+
for (var _match of query.matchAll(typePattern)) {
|
|
494
|
+
var _name = _match[1].replace(/''/g, "'").trim();
|
|
495
|
+
|
|
496
|
+
if (_name) names.add(_name.toLowerCase());
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return names;
|
|
500
|
+
}
|
|
438
501
|
|
|
439
502
|
module.exports = {
|
|
440
503
|
buildCoveringIndex,
|
|
441
504
|
buildCoveringIndexDefinition,
|
|
442
505
|
indexSatisfiesCoveringDefinition,
|
|
506
|
+
parseResourceList,
|
|
507
|
+
extractStatementResources,
|
|
443
508
|
extractWhereFields,
|
|
444
509
|
extractSelectFields,
|
|
445
510
|
extractGroupByFields,
|
package/build/utility.test.js
CHANGED
|
@@ -8,7 +8,9 @@ var {
|
|
|
8
8
|
extractSelectFields,
|
|
9
9
|
extractGroupByFields,
|
|
10
10
|
extractOrderByFields,
|
|
11
|
-
generateHash
|
|
11
|
+
generateHash,
|
|
12
|
+
parseResourceList,
|
|
13
|
+
extractStatementResources
|
|
12
14
|
} = require("./utility"); // Test utilities
|
|
13
15
|
|
|
14
16
|
|
|
@@ -825,6 +827,52 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
|
|
|
825
827
|
|
|
826
828
|
assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
|
|
827
829
|
}); // ============================================
|
|
830
|
+
// Tests for parseResourceList / extractStatementResources
|
|
831
|
+
// ============================================
|
|
832
|
+
|
|
833
|
+
console.log("\n--- parseResourceList tests ---");
|
|
834
|
+
test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
|
|
835
|
+
var result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
|
|
836
|
+
assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
|
|
837
|
+
});
|
|
838
|
+
test("accepts arrays and Sets and ignores non-strings", () => {
|
|
839
|
+
assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
|
|
840
|
+
assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
|
|
841
|
+
});
|
|
842
|
+
test("returns an empty set for undefined, empty or unsupported input", () => {
|
|
843
|
+
assert.strictEqual(parseResourceList(undefined).size, 0);
|
|
844
|
+
assert.strictEqual(parseResourceList("").size, 0);
|
|
845
|
+
assert.strictEqual(parseResourceList(", ,").size, 0);
|
|
846
|
+
assert.strictEqual(parseResourceList({
|
|
847
|
+
users: true
|
|
848
|
+
}).size, 0);
|
|
849
|
+
});
|
|
850
|
+
console.log("\n--- extractStatementResources tests ---");
|
|
851
|
+
test("extracts a bare FROM keyspace", () => {
|
|
852
|
+
assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
|
|
853
|
+
});
|
|
854
|
+
test("extracts the last segment of a qualified or quoted keyspace", () => {
|
|
855
|
+
assert.deepStrictEqual([...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")], ["users"]);
|
|
856
|
+
assert.deepStrictEqual([...extractStatementResources("SELECT name FROM saturn.app.logs")], ["logs"]);
|
|
857
|
+
});
|
|
858
|
+
test("extracts JOIN keyspaces alongside FROM", () => {
|
|
859
|
+
var result = extractStatementResources("SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id");
|
|
860
|
+
assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
|
|
861
|
+
});
|
|
862
|
+
test("extracts type literals used by legacy bucket mode", () => {
|
|
863
|
+
var result = extractStatementResources("SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false");
|
|
864
|
+
assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
|
|
865
|
+
assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
|
|
866
|
+
assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
|
|
867
|
+
});
|
|
868
|
+
test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
|
|
869
|
+
var result = extractStatementResources("SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'");
|
|
870
|
+
assert.deepStrictEqual([...result], ["users"]);
|
|
871
|
+
});
|
|
872
|
+
test("handles non-string input", () => {
|
|
873
|
+
assert.strictEqual(extractStatementResources(undefined).size, 0);
|
|
874
|
+
assert.strictEqual(extractStatementResources(null).size, 0);
|
|
875
|
+
}); // ============================================
|
|
828
876
|
// Summary
|
|
829
877
|
// ============================================
|
|
830
878
|
|
package/package.json
CHANGED
|
@@ -37,7 +37,8 @@ function createStatements(scope) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Isolate optimizer state and time without connecting to Couchbase or sleeping.
|
|
40
|
-
|
|
40
|
+
// Each load is a fresh module: its own seen-set, cooldown and policy.
|
|
41
|
+
function loadDriver(durationMs, env = {}) {
|
|
41
42
|
let tick = BigInt(0);
|
|
42
43
|
const module = { exports: {} };
|
|
43
44
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
|
|
@@ -55,7 +56,11 @@ function createDatabase(durationMs, env = {}) {
|
|
|
55
56
|
},
|
|
56
57
|
},
|
|
57
58
|
});
|
|
58
|
-
|
|
59
|
+
return module.exports;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function instanceOf(Database) {
|
|
63
|
+
const db = Object.create(Database.prototype);
|
|
59
64
|
db.bucket = "saturn";
|
|
60
65
|
db.scope_name = "test";
|
|
61
66
|
db.collection_name = "users";
|
|
@@ -63,6 +68,13 @@ function createDatabase(durationMs, env = {}) {
|
|
|
63
68
|
return db;
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
// Opted in wholesale: per-resource resolution has its own tests further down.
|
|
72
|
+
function createDatabase(durationMs, env = {}) {
|
|
73
|
+
const db = instanceOf(loadDriver(durationMs, env));
|
|
74
|
+
db.auto_optimize = true;
|
|
75
|
+
return db;
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
test("auto-optimization only starts above the default 3-second threshold", async () => {
|
|
67
79
|
for (const duration of [1001, 2999, 3000, 3001]) {
|
|
68
80
|
const db = createDatabase(duration);
|
|
@@ -235,3 +247,147 @@ test("a default collection uses default names in its inventory lookup", async ()
|
|
|
235
247
|
}));
|
|
236
248
|
assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
|
|
237
249
|
});
|
|
250
|
+
|
|
251
|
+
// ---- Per-resource opt-in for auto cover indexing ----
|
|
252
|
+
|
|
253
|
+
// Nothing decided at instance level: resolution falls through to the call,
|
|
254
|
+
// the application policy and the static lists.
|
|
255
|
+
function createUnconfiguredDatabase(env = {}) {
|
|
256
|
+
return instanceOf(loadDriver(3001, env));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function optimizationsFor(db, statement, ...queryArgs) {
|
|
260
|
+
let optimizations = 0;
|
|
261
|
+
db.optimize = async () => { optimizations += 1; };
|
|
262
|
+
await db.query(statement, ...queryArgs);
|
|
263
|
+
return optimizations;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const slowLookup = "SELECT name FROM users WHERE status = 'active'";
|
|
267
|
+
const EVERY_RESOURCE = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "*" };
|
|
268
|
+
|
|
269
|
+
test("no resource is auto-indexed unless something opts it in", async () => {
|
|
270
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup), 0);
|
|
271
|
+
assert.equal(await optimizationsFor(
|
|
272
|
+
createUnconfiguredDatabase({ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "" }), slowLookup), 0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("SPICE_COUCHBASE_OPTIMIZE_RESOURCES enables the instance's collection, case-insensitively", async () => {
|
|
276
|
+
const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: " Users ,audit_log,, `sessions` " };
|
|
277
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(env), slowLookup), 1);
|
|
278
|
+
|
|
279
|
+
const sessions = createUnconfiguredDatabase(env);
|
|
280
|
+
sessions.collection_name = "sessions";
|
|
281
|
+
assert.equal(await optimizationsFor(sessions, "SELECT id FROM sessions"), 1);
|
|
282
|
+
|
|
283
|
+
const orders = createUnconfiguredDatabase(env);
|
|
284
|
+
orders.collection_name = "orders";
|
|
285
|
+
assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a wildcard restores auto indexing for every resource", async () => {
|
|
289
|
+
const db = createUnconfiguredDatabase(EVERY_RESOURCE);
|
|
290
|
+
db.collection_name = "anything";
|
|
291
|
+
assert.equal(await optimizationsFor(db, "SELECT a FROM anything WHERE b = 1"), 1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("legacy bucket mode resolves the resource from the type literal the driver adds", async () => {
|
|
295
|
+
const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "audit_log" };
|
|
296
|
+
for (const [type, expected] of [["audit_log", 1], ["users", 0]]) {
|
|
297
|
+
const db = createUnconfiguredDatabase(env);
|
|
298
|
+
db.collection_name = "_default";
|
|
299
|
+
db.scope_name = "_default";
|
|
300
|
+
assert.equal(await optimizationsFor(db,
|
|
301
|
+
`SELECT actor FROM \`saturn\` WHERE type = '${type}' AND deleted = false`), expected, type);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a fully qualified or aliased FROM keyspace matches on its last segment", async () => {
|
|
306
|
+
const env = { SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "logs" };
|
|
307
|
+
for (const statement of [
|
|
308
|
+
"SELECT level FROM `default`:`saturn`.`app`.`logs` WHERE level = 'error'",
|
|
309
|
+
"SELECT l.level FROM saturn.app.logs AS l WHERE l.level = 'error'",
|
|
310
|
+
"SELECT u.name FROM users AS u JOIN `logs` AS l ON l.user_id = META(u).id",
|
|
311
|
+
]) {
|
|
312
|
+
const db = createUnconfiguredDatabase(env);
|
|
313
|
+
db.collection_name = "_default";
|
|
314
|
+
assert.equal(await optimizationsFor(db, statement), 1, statement);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("connection or constructor lists work without the environment variable", async () => {
|
|
319
|
+
const db = createUnconfiguredDatabase();
|
|
320
|
+
db.enabled_resources = new Set(["users"]);
|
|
321
|
+
assert.equal(await optimizationsFor(db, slowLookup), 1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("an explicit instance setting overrides every resource list", async () => {
|
|
325
|
+
const on = createUnconfiguredDatabase();
|
|
326
|
+
on.auto_optimize = true;
|
|
327
|
+
assert.equal(await optimizationsFor(on, slowLookup), 1);
|
|
328
|
+
|
|
329
|
+
const off = createUnconfiguredDatabase(EVERY_RESOURCE);
|
|
330
|
+
off.auto_optimize = false;
|
|
331
|
+
assert.equal(await optimizationsFor(off, slowLookup), 0);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a single call can opt in or out through query options", async () => {
|
|
335
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, { auto_optimize: true }), 1);
|
|
336
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, { auto_optimize: false }), 0);
|
|
337
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", { optimize: false }), 0);
|
|
338
|
+
// The scan-consistency shorthand is not an opt-in or opt-out.
|
|
339
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(EVERY_RESOURCE), slowLookup, "cluster", true), 1);
|
|
340
|
+
assert.equal(await optimizationsFor(createUnconfiguredDatabase(), slowLookup, "cluster", true), 0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("an application policy decides per resource and sees the statement context", async () => {
|
|
344
|
+
const Database = loadDriver(3001);
|
|
345
|
+
const seen = [];
|
|
346
|
+
Database.setAutoOptimizePolicy((resource, context) => {
|
|
347
|
+
seen.push([resource, context]);
|
|
348
|
+
return resource === "users";
|
|
349
|
+
});
|
|
350
|
+
assert.equal(Database.getAutoOptimizePolicy().length, 2);
|
|
351
|
+
|
|
352
|
+
const users = instanceOf(Database);
|
|
353
|
+
users.connection_name = "default";
|
|
354
|
+
assert.equal(await optimizationsFor(users, slowLookup), 1);
|
|
355
|
+
// Objects cross the vm boundary, so compare by value rather than prototype.
|
|
356
|
+
assert.equal(JSON.stringify(seen[0]), JSON.stringify(["users", { query: slowLookup, connection: "default", collection: "users" }]));
|
|
357
|
+
|
|
358
|
+
const orders = instanceOf(Database);
|
|
359
|
+
orders.collection_name = "orders";
|
|
360
|
+
assert.equal(await optimizationsFor(orders, "SELECT total FROM orders WHERE status = 'paid'"), 0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("a policy that returns undefined defers to the static lists", async () => {
|
|
364
|
+
for (const [env, expected] of [[{ SPICE_COUCHBASE_OPTIMIZE_RESOURCES: "users" }, 1], [{}, 0]]) {
|
|
365
|
+
const Database = loadDriver(3001, env);
|
|
366
|
+
Database.setAutoOptimizePolicy(() => undefined);
|
|
367
|
+
assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), expected);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("an explicit policy refusal wins over the wildcard, and a throwing policy counts as a refusal", async () => {
|
|
372
|
+
for (const policy of [() => false, () => { throw new Error("settings unavailable"); }]) {
|
|
373
|
+
const Database = loadDriver(3001, EVERY_RESOURCE);
|
|
374
|
+
Database.setAutoOptimizePolicy(policy);
|
|
375
|
+
assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 0);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("the policy can be cleared and must be a function", async () => {
|
|
380
|
+
const Database = loadDriver(3001, EVERY_RESOURCE);
|
|
381
|
+
Database.setAutoOptimizePolicy(() => false);
|
|
382
|
+
Database.setAutoOptimizePolicy(null);
|
|
383
|
+
assert.equal(Database.getAutoOptimizePolicy(), null);
|
|
384
|
+
assert.equal(await optimizationsFor(instanceOf(Database), slowLookup), 1);
|
|
385
|
+
assert.throws(() => Database.setAutoOptimizePolicy("users"), { name: "TypeError" });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("a declined statement is not remembered as already optimised", async () => {
|
|
389
|
+
const db = createUnconfiguredDatabase();
|
|
390
|
+
assert.equal(await optimizationsFor(db, slowLookup), 0);
|
|
391
|
+
db.auto_optimize = true;
|
|
392
|
+
assert.equal(await optimizationsFor(db, slowLookup), 1);
|
|
393
|
+
});
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ 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");
|
|
7
8
|
|
|
8
9
|
if (!Promise.allSettled) {
|
|
9
10
|
Promise.allSettled = (promises) =>
|
|
@@ -50,6 +51,37 @@ const optimizeState = {
|
|
|
50
51
|
seen: new Set(),
|
|
51
52
|
cooldownUntil: 0,
|
|
52
53
|
};
|
|
54
|
+
// Auto cover indexing is opt-in per resource. A resource is the collection a
|
|
55
|
+
// Database instance serves (scoped deployments) or the `type = '...'` literal
|
|
56
|
+
// legacy bucket mode adds to every statement; raw statements also count their
|
|
57
|
+
// FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
|
|
58
|
+
// optimised when, in order of precedence:
|
|
59
|
+
// 1. the call says so: query_options.auto_optimize (true / false)
|
|
60
|
+
// 2. the instance says so: constructor option or connection config
|
|
61
|
+
// `auto_optimize` (true / false)
|
|
62
|
+
// 3. the application policy: Database.setAutoOptimizePolicy(fn), where
|
|
63
|
+
// fn(resource, context) returns true / false, or
|
|
64
|
+
// undefined to defer to the static lists
|
|
65
|
+
// 4. a static list names a resource it touches: comma-separated
|
|
66
|
+
// SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
|
|
67
|
+
// pre-2.0.62 behaviour), or connection config / constructor option
|
|
68
|
+
// `auto_optimize_resources`.
|
|
69
|
+
const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
|
|
70
|
+
process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
|
|
71
|
+
);
|
|
72
|
+
let autoOptimizePolicy = null;
|
|
73
|
+
|
|
74
|
+
function requestedAutoOptimize(query_options) {
|
|
75
|
+
if (
|
|
76
|
+
!query_options ||
|
|
77
|
+
typeof query_options !== "object" ||
|
|
78
|
+
Array.isArray(query_options)
|
|
79
|
+
) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const value = query_options.auto_optimize ?? query_options.optimize;
|
|
83
|
+
return typeof value === "boolean" ? value : undefined;
|
|
84
|
+
}
|
|
53
85
|
|
|
54
86
|
function shouldOptimize(query) {
|
|
55
87
|
if (!AUTO_OPTIMIZE_ENABLED) return false;
|
|
@@ -126,6 +158,8 @@ module.exports = class Database {
|
|
|
126
158
|
scope,
|
|
127
159
|
query_scope = "cluster",
|
|
128
160
|
auto_create_collection,
|
|
161
|
+
auto_optimize,
|
|
162
|
+
auto_optimize_resources,
|
|
129
163
|
} = {},
|
|
130
164
|
) {
|
|
131
165
|
if (connection) {
|
|
@@ -181,7 +215,94 @@ module.exports = class Database {
|
|
|
181
215
|
this.use_bucket_name =
|
|
182
216
|
spice.config.database.connections[this.connection_name].scope ==
|
|
183
217
|
undefined;
|
|
218
|
+
|
|
219
|
+
// Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
|
|
220
|
+
const configured_auto_optimize =
|
|
221
|
+
auto_optimize ?? this.db_config.auto_optimize;
|
|
222
|
+
this.auto_optimize =
|
|
223
|
+
typeof configured_auto_optimize === "boolean"
|
|
224
|
+
? configured_auto_optimize
|
|
225
|
+
: undefined;
|
|
226
|
+
this.enabled_resources = parseResourceList([
|
|
227
|
+
...parseResourceList(this.db_config.auto_optimize_resources),
|
|
228
|
+
...parseResourceList(auto_optimize_resources),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Lets the application decide per resource whether slow statements may
|
|
235
|
+
* create covering indexes, e.g. from a per-resource setting an operator
|
|
236
|
+
* toggles at runtime. `policy(resource, { query, connection, collection })`
|
|
237
|
+
* returns true or false, or undefined to defer to the static lists. It is
|
|
238
|
+
* synchronous because it runs on the query path. Pass null to clear.
|
|
239
|
+
*/
|
|
240
|
+
static setAutoOptimizePolicy(policy) {
|
|
241
|
+
if (
|
|
242
|
+
policy !== null &&
|
|
243
|
+
policy !== undefined &&
|
|
244
|
+
typeof policy !== "function"
|
|
245
|
+
) {
|
|
246
|
+
throw new TypeError("Auto-optimize policy must be a function or null");
|
|
247
|
+
}
|
|
248
|
+
autoOptimizePolicy = policy || null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
static getAutoOptimizePolicy() {
|
|
252
|
+
return autoOptimizePolicy;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resources a statement touches, plus this instance's collection: that is
|
|
257
|
+
* the keyspace `optimize()` would create the index on.
|
|
258
|
+
*/
|
|
259
|
+
statementResources(query) {
|
|
260
|
+
const resources = extractStatementResources(query);
|
|
261
|
+
if (this.collection_name && this.collection_name !== "_default") {
|
|
262
|
+
resources.add(String(this.collection_name).toLowerCase());
|
|
184
263
|
}
|
|
264
|
+
return resources;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* True when a slow statement may create a covering index. See
|
|
269
|
+
* OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
|
|
270
|
+
*/
|
|
271
|
+
shouldAutoOptimize(query, query_options = {}) {
|
|
272
|
+
const requested = requestedAutoOptimize(query_options);
|
|
273
|
+
if (requested !== undefined) return requested;
|
|
274
|
+
if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
|
|
275
|
+
|
|
276
|
+
const resources = this.statementResources(query);
|
|
277
|
+
if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
|
|
278
|
+
let deferred = false;
|
|
279
|
+
for (const resource of resources) {
|
|
280
|
+
let verdict;
|
|
281
|
+
try {
|
|
282
|
+
verdict = autoOptimizePolicy(resource, {
|
|
283
|
+
query,
|
|
284
|
+
connection: this.connection_name,
|
|
285
|
+
collection: this.collection_name,
|
|
286
|
+
});
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// A broken policy must not create indexes nobody asked for.
|
|
289
|
+
verdict = false;
|
|
290
|
+
}
|
|
291
|
+
if (verdict === true) return true;
|
|
292
|
+
if (verdict === undefined || verdict === null) deferred = true;
|
|
293
|
+
}
|
|
294
|
+
if (!deferred) return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const enabled = new Set([
|
|
298
|
+
...OPTIMIZE_ENABLED_RESOURCES,
|
|
299
|
+
...(this.enabled_resources || []),
|
|
300
|
+
]);
|
|
301
|
+
if (enabled.has("*")) return true;
|
|
302
|
+
for (const resource of resources) {
|
|
303
|
+
if (enabled.has(resource)) return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
185
306
|
}
|
|
186
307
|
|
|
187
308
|
setGlobalConnectionObject(collection) {
|
|
@@ -956,6 +1077,7 @@ module.exports = class Database {
|
|
|
956
1077
|
// declared at the top of this file. Never awaited, never allowed to reject.
|
|
957
1078
|
if (
|
|
958
1079
|
durationInMilliseconds > SLOW_QUERY_MS &&
|
|
1080
|
+
this.shouldAutoOptimize(sanitizedQuery, query_options) &&
|
|
959
1081
|
shouldOptimize(sanitizedQuery)
|
|
960
1082
|
) {
|
|
961
1083
|
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
|
// ============================================
|